更新点歌和弹幕微调
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
type QueueItem = { id: string }
|
||||
|
||||
type EffectQueueState<T extends QueueItem> = {
|
||||
active?: T
|
||||
pending: T[]
|
||||
}
|
||||
|
||||
export function enqueueEffect<T extends QueueItem>(
|
||||
state: EffectQueueState<T>,
|
||||
effect: T,
|
||||
capacity: number,
|
||||
): EffectQueueState<T> {
|
||||
if (state.active?.id === effect.id || state.pending.some(item => item.id === effect.id)) {
|
||||
return state
|
||||
}
|
||||
if (!state.active) return { active: effect, pending: state.pending }
|
||||
const boundedCapacity = Number.isFinite(capacity) ? Math.max(1, Math.floor(capacity)) : 1
|
||||
if (state.pending.length >= boundedCapacity) return state
|
||||
return { ...state, pending: [...state.pending, effect] }
|
||||
}
|
||||
|
||||
export function completeEffect<T extends QueueItem>(
|
||||
state: EffectQueueState<T>,
|
||||
activeId: string,
|
||||
): EffectQueueState<T> {
|
||||
if (state.active?.id !== activeId) return state
|
||||
const [active, ...pending] = state.pending
|
||||
return { active, pending }
|
||||
}
|
||||
|
||||
export function useEffectQueue<T extends QueueItem>() {
|
||||
const [state, setState] = useState<EffectQueueState<T>>({ pending: [] })
|
||||
const enqueue = useCallback((effect: T, capacity: number) => {
|
||||
setState(current => enqueueEffect(current, effect, capacity))
|
||||
}, [])
|
||||
const complete = useCallback((activeId: string) => {
|
||||
setState(current => completeEffect(current, activeId))
|
||||
}, [])
|
||||
return { active: state.active, pendingCount: state.pending.length, enqueue, complete }
|
||||
}
|
||||
@@ -5,3 +5,11 @@
|
||||
font-weight: 400;
|
||||
src: url('/fonts/liyu-shoushu-v0.107.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'HongLei XingShu';
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
font-weight: 400;
|
||||
src: url('/fonts/honglei-xingshu-jian.woff2') format('woff2');
|
||||
}
|
||||
|
||||
+14
-237
@@ -18,6 +18,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meteor-burst {
|
||||
animation: gift-burst-lifetime var(--burst-duration) linear both;
|
||||
}
|
||||
|
||||
.gift-meteor {
|
||||
position: absolute;
|
||||
top: var(--meteor-y);
|
||||
@@ -156,122 +160,7 @@
|
||||
animation-delay: -360ms;
|
||||
}
|
||||
|
||||
.guard-celebration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
color: #edfffa;
|
||||
background:
|
||||
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
|
||||
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
|
||||
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--gift-night);
|
||||
animation: var(--gift-motion-guard, gift-guard-reveal) var(--guard-duration) ease-in-out both;
|
||||
}
|
||||
|
||||
.guard-nebula {
|
||||
position: absolute;
|
||||
inset: -25%;
|
||||
background: conic-gradient(
|
||||
from 90deg,
|
||||
transparent,
|
||||
rgba(93, 237, 209, 0.18),
|
||||
transparent 32%,
|
||||
rgba(255, 207, 230, 0.12),
|
||||
transparent 68%,
|
||||
rgba(255, 230, 156, 0.13),
|
||||
transparent
|
||||
);
|
||||
filter: blur(28px);
|
||||
animation: gift-nebula-turn 9s linear infinite;
|
||||
}
|
||||
|
||||
.guard-stars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.guard-stars i {
|
||||
position: absolute;
|
||||
width: var(--star-size);
|
||||
height: var(--star-size);
|
||||
opacity: 0.1;
|
||||
background: linear-gradient(135deg, #fff8ca, var(--gift-cyan) 58%, var(--gift-rose));
|
||||
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
|
||||
filter: drop-shadow(0 0 6px var(--gift-cyan));
|
||||
animation: var(--gift-motion-star, gift-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
|
||||
}
|
||||
|
||||
.guard-halo {
|
||||
position: absolute;
|
||||
width: min(62vmin, 720px);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid rgba(155, 255, 235, 0.4);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 60px rgba(87, 241, 212, 0.25),
|
||||
inset 0 0 70px rgba(255, 224, 166, 0.12);
|
||||
animation: gift-halo-breathe 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.guard-halo i {
|
||||
position: absolute;
|
||||
inset: 7%;
|
||||
border: 1px solid rgba(255, 227, 170, 0.38);
|
||||
border-radius: 45% 55% 48% 52%;
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
|
||||
.guard-halo i:nth-child(2) {
|
||||
inset: 15%;
|
||||
border-color: rgba(255, 195, 224, 0.32);
|
||||
transform: rotate(76deg);
|
||||
}
|
||||
|
||||
.guard-halo i:nth-child(3) {
|
||||
inset: 23%;
|
||||
border-color: rgba(123, 246, 224, 0.45);
|
||||
transform: rotate(122deg);
|
||||
}
|
||||
|
||||
.guard-copy {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
max-width: min(82vw, 1000px);
|
||||
justify-items: center;
|
||||
gap: clamp(8px, 1.5vh, 20px);
|
||||
text-align: center;
|
||||
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
|
||||
}
|
||||
|
||||
.guard-copy span {
|
||||
color: var(--gift-gold);
|
||||
font-size: clamp(13px, 1.6vw, 30px);
|
||||
letter-spacing: 0.45em;
|
||||
}
|
||||
|
||||
.guard-copy strong {
|
||||
color: #f3fffc;
|
||||
font-size: clamp(38px, 7vw, 132px);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
|
||||
}
|
||||
|
||||
.guard-copy b {
|
||||
color: var(--gift-rose);
|
||||
font-size: clamp(16px, 2.3vw, 44px);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.gift-low-motion .meteor-spark,
|
||||
.gift-low-motion .guard-nebula,
|
||||
.gift-low-motion .guard-halo {
|
||||
.gift-low-motion .meteor-spark {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -293,6 +182,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-burst-lifetime {
|
||||
from,
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-meteor-sparkle {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
@@ -304,53 +200,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-guard-reveal {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
8%,
|
||||
86% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-star-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.08;
|
||||
transform: rotate(0) scale(0.45);
|
||||
}
|
||||
48% {
|
||||
opacity: 1;
|
||||
transform: rotate(50deg) scale(1.32);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-nebula-turn {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-halo-breathe {
|
||||
50% {
|
||||
transform: scale(1.08) rotate(3deg);
|
||||
box-shadow:
|
||||
0 0 110px rgba(87, 241, 212, 0.38),
|
||||
inset 0 0 90px rgba(255, 224, 166, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gift-meteor {
|
||||
animation-duration: max(var(--meteor-duration), 6s);
|
||||
}
|
||||
|
||||
.meteor-spark,
|
||||
.guard-nebula,
|
||||
.guard-stars i,
|
||||
.guard-halo {
|
||||
.meteor-spark {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -369,8 +224,7 @@
|
||||
|
||||
.moonlit-whisper-copy,
|
||||
.moonlit-gift-whisper > small,
|
||||
.moonlit-ceremony-copy,
|
||||
.guard-copy {
|
||||
.moonlit-ceremony-copy {
|
||||
filter: brightness(var(--component-font-brightness, 1.3));
|
||||
}
|
||||
|
||||
@@ -617,76 +471,6 @@
|
||||
animation: moonlit-image-halo 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Reuse the membership DOM but make its backdrop transparent and turn the
|
||||
generic nebula into a quiet, full-scene moon-and-water celebration. */
|
||||
.gift-theme-moonlit-water .guard-celebration {
|
||||
color: #eadfc3;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-celebration::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(62vmin, 780px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(246, 237, 207, 0.82) 0 42%,
|
||||
rgba(226, 224, 193, 0.2) 63%,
|
||||
transparent 71%
|
||||
);
|
||||
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-nebula {
|
||||
background:
|
||||
repeating-radial-gradient(
|
||||
ellipse at 50% 80%,
|
||||
transparent 0 7%,
|
||||
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
|
||||
transparent 7.7% 12%
|
||||
),
|
||||
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
|
||||
filter: blur(1px);
|
||||
animation: moonlit-guard-water 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-stars i {
|
||||
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
|
||||
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-halo {
|
||||
width: min(77vmin, 930px);
|
||||
border-color: rgba(231, 201, 130, 0.42);
|
||||
box-shadow:
|
||||
0 0 44px rgba(231, 201, 130, 0.18),
|
||||
inset 0 0 65px rgba(182, 207, 180, 0.1);
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-halo i {
|
||||
border-color: rgba(203, 219, 184, 0.36);
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-copy {
|
||||
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-copy span,
|
||||
.gift-theme-moonlit-water .guard-copy b {
|
||||
color: #e7c982;
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water .guard-copy strong {
|
||||
color: #eadfc3;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-moon,
|
||||
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-water i,
|
||||
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-stars,
|
||||
@@ -784,10 +568,3 @@
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moonlit-guard-water {
|
||||
50% {
|
||||
opacity: 0.68;
|
||||
transform: scale(1.04) translateY(-1.4%);
|
||||
}
|
||||
}
|
||||
|
||||
+108
-142
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,9 @@ export type GiftEffectTheme = {
|
||||
jade: string
|
||||
cyan: string
|
||||
gold: string
|
||||
rose: string
|
||||
night: string
|
||||
}
|
||||
motion: {
|
||||
meteor: string
|
||||
guardReveal: string
|
||||
starPulse: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +26,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
|
||||
jade: '#72f3d8',
|
||||
cyan: '#a9fff2',
|
||||
gold: '#ffe7a4',
|
||||
rose: '#ffd0e5',
|
||||
night: '#020c18',
|
||||
},
|
||||
motion: {
|
||||
meteor: 'gift-meteor-flight',
|
||||
guardReveal: 'gift-guard-reveal',
|
||||
starPulse: 'gift-star-pulse',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,13 +40,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
|
||||
jade: '#8daea2',
|
||||
cyan: '#c6d8c6',
|
||||
gold: '#e7c982',
|
||||
rose: '#dbc6a0',
|
||||
night: 'transparent',
|
||||
},
|
||||
motion: {
|
||||
meteor: 'moonlit-offering-sweep',
|
||||
guardReveal: 'moonlit-guard-reveal',
|
||||
starPulse: 'moonlit-star-pulse',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -72,10 +60,6 @@ export function giftThemeVariables(theme: GiftEffectTheme): CSSProperties {
|
||||
['--gift-jade' as string]: theme.palette.jade,
|
||||
['--gift-cyan' as string]: theme.palette.cyan,
|
||||
['--gift-gold' as string]: theme.palette.gold,
|
||||
['--gift-rose' as string]: theme.palette.rose,
|
||||
['--gift-night' as string]: theme.palette.night,
|
||||
['--gift-motion-meteor' as string]: theme.motion.meteor,
|
||||
['--gift-motion-guard' as string]: theme.motion.guardReveal,
|
||||
['--gift-motion-star' as string]: theme.motion.starPulse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
.guard-effect-overlay {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--guard-cyan, #a9fff2);
|
||||
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jade-guard-voyage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.jade-guard-voyage > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
animation: jade-guard-video-fade var(--guard-duration) linear both;
|
||||
}
|
||||
|
||||
.jade-guard-scroll {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: min(86vw, 1180px);
|
||||
min-height: min(34vh, 360px);
|
||||
padding: clamp(20px, 3.2vh, 42px) clamp(54px, 8vw, 130px);
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: clamp(5px, 0.9vh, 12px);
|
||||
border-block: 1px solid rgba(255, 229, 153, 0.7);
|
||||
color: #fff4cf;
|
||||
font-family: 'HongLei XingShu', 'Liyu Shoushu', cursive;
|
||||
text-align: center;
|
||||
text-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.96),
|
||||
0 0 14px rgba(0, 0, 0, 0.92),
|
||||
0 0 22px rgba(255, 220, 130, 0.48);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(3, 15, 22, 0.34) 12%,
|
||||
rgba(3, 15, 22, 0.62) 50%,
|
||||
rgba(3, 15, 22, 0.34) 88%,
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
0 -8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
clip-path: inset(0 50% round 10px);
|
||||
transform: translate(-56%, -50%);
|
||||
will-change: clip-path, opacity, transform;
|
||||
animation: jade-guard-scroll-reveal 4s cubic-bezier(0.22, 0.78, 0.22, 1) 4s both;
|
||||
}
|
||||
|
||||
.jade-guard-scroll::before,
|
||||
.jade-guard-scroll::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7%;
|
||||
bottom: -7%;
|
||||
width: clamp(5px, 0.55vw, 10px);
|
||||
border: 1px solid rgba(255, 239, 190, 0.82);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #806329, #fff0b3 48%, #947132);
|
||||
box-shadow: 0 0 14px rgba(255, 224, 145, 0.5);
|
||||
}
|
||||
|
||||
.jade-guard-scroll::before {
|
||||
left: clamp(13px, 2vw, 32px);
|
||||
}
|
||||
|
||||
.jade-guard-scroll::after {
|
||||
right: clamp(13px, 2vw, 32px);
|
||||
}
|
||||
|
||||
.jade-guard-scroll strong,
|
||||
.jade-guard-scroll b,
|
||||
.jade-guard-scroll span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jade-guard-scroll strong {
|
||||
color: #fff5d2;
|
||||
font-size: clamp(42px, 7vw, 126px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.jade-guard-scroll b {
|
||||
color: #d4fff3;
|
||||
font-size: clamp(24px, 3.7vw, 66px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.jade-guard-scroll span {
|
||||
color: #ffe5a6;
|
||||
font-size: clamp(34px, 5.4vw, 94px);
|
||||
letter-spacing: 0.18em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.guard-celebration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
color: #edfffa;
|
||||
background:
|
||||
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
|
||||
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
|
||||
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--guard-night);
|
||||
animation: var(--guard-motion-reveal, guard-effect-reveal) var(--guard-duration) ease-in-out both;
|
||||
}
|
||||
|
||||
.guard-nebula {
|
||||
position: absolute;
|
||||
inset: -25%;
|
||||
background: conic-gradient(
|
||||
from 90deg,
|
||||
transparent,
|
||||
rgba(93, 237, 209, 0.18),
|
||||
transparent 32%,
|
||||
rgba(255, 207, 230, 0.12),
|
||||
transparent 68%,
|
||||
rgba(255, 230, 156, 0.13),
|
||||
transparent
|
||||
);
|
||||
filter: blur(28px);
|
||||
animation: guard-nebula-turn 9s linear infinite;
|
||||
}
|
||||
|
||||
.guard-stars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.guard-stars i {
|
||||
position: absolute;
|
||||
width: var(--star-size);
|
||||
height: var(--star-size);
|
||||
opacity: 0.1;
|
||||
background: linear-gradient(135deg, #fff8ca, var(--guard-cyan) 58%, var(--guard-rose));
|
||||
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
|
||||
filter: drop-shadow(0 0 6px var(--guard-cyan));
|
||||
animation: var(--guard-motion-star, guard-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
|
||||
}
|
||||
|
||||
.guard-halo {
|
||||
position: absolute;
|
||||
width: min(62vmin, 720px);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid rgba(155, 255, 235, 0.4);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 60px rgba(87, 241, 212, 0.25),
|
||||
inset 0 0 70px rgba(255, 224, 166, 0.12);
|
||||
animation: guard-halo-breathe 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.guard-halo i {
|
||||
position: absolute;
|
||||
inset: 7%;
|
||||
border: 1px solid rgba(255, 227, 170, 0.38);
|
||||
border-radius: 45% 55% 48% 52%;
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
|
||||
.guard-halo i:nth-child(2) {
|
||||
inset: 15%;
|
||||
border-color: rgba(255, 195, 224, 0.32);
|
||||
transform: rotate(76deg);
|
||||
}
|
||||
|
||||
.guard-halo i:nth-child(3) {
|
||||
inset: 23%;
|
||||
border-color: rgba(123, 246, 224, 0.45);
|
||||
transform: rotate(122deg);
|
||||
}
|
||||
|
||||
.guard-copy {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
max-width: min(82vw, 1000px);
|
||||
justify-items: center;
|
||||
gap: clamp(8px, 1.5vh, 20px);
|
||||
text-align: center;
|
||||
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
|
||||
filter: brightness(var(--component-font-brightness, 1.3));
|
||||
}
|
||||
|
||||
.guard-copy span {
|
||||
color: var(--guard-gold);
|
||||
font-size: clamp(13px, 1.6vw, 30px);
|
||||
letter-spacing: 0.45em;
|
||||
}
|
||||
|
||||
.guard-copy strong {
|
||||
color: #f3fffc;
|
||||
font-size: clamp(38px, 7vw, 132px);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
|
||||
}
|
||||
|
||||
.guard-copy b {
|
||||
color: var(--guard-rose);
|
||||
font-size: clamp(16px, 2.3vw, 44px);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.guard-low-motion .guard-nebula,
|
||||
.guard-low-motion .guard-halo {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water {
|
||||
color: #eadfc3;
|
||||
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-celebration {
|
||||
color: #eadfc3;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-celebration::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(62vmin, 780px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(246, 237, 207, 0.82) 0 42%,
|
||||
rgba(226, 224, 193, 0.2) 63%,
|
||||
transparent 71%
|
||||
);
|
||||
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-nebula {
|
||||
background:
|
||||
repeating-radial-gradient(
|
||||
ellipse at 50% 80%,
|
||||
transparent 0 7%,
|
||||
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
|
||||
transparent 7.7% 12%
|
||||
),
|
||||
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
|
||||
filter: blur(1px);
|
||||
animation: moonlit-guard-water 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-stars i {
|
||||
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
|
||||
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-halo {
|
||||
width: min(77vmin, 930px);
|
||||
border-color: rgba(231, 201, 130, 0.42);
|
||||
box-shadow:
|
||||
0 0 44px rgba(231, 201, 130, 0.18),
|
||||
inset 0 0 65px rgba(182, 207, 180, 0.1);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-halo i {
|
||||
border-color: rgba(203, 219, 184, 0.36);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-copy {
|
||||
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-copy span,
|
||||
.guard-theme-moonlit-water .guard-copy b {
|
||||
color: #e7c982;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-copy strong {
|
||||
color: #eadfc3;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@keyframes guard-effect-reveal {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
8%,
|
||||
86% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jade-guard-video-fade {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
6.25%,
|
||||
93.75% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jade-guard-scroll-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 50% round 10px);
|
||||
transform: translate(-56%, -50%);
|
||||
}
|
||||
12.5%,
|
||||
87.5% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 round 10px);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 round 10px);
|
||||
transform: translate(-46%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jade-guard-copy-fade {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
12.5%,
|
||||
87.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guard-star-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.08;
|
||||
transform: rotate(0) scale(0.45);
|
||||
}
|
||||
48% {
|
||||
opacity: 1;
|
||||
transform: rotate(50deg) scale(1.32);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guard-nebula-turn {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guard-halo-breathe {
|
||||
50% {
|
||||
transform: scale(1.08) rotate(3deg);
|
||||
box-shadow:
|
||||
0 0 110px rgba(87, 241, 212, 0.38),
|
||||
inset 0 0 90px rgba(255, 224, 166, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moonlit-guard-water {
|
||||
50% {
|
||||
opacity: 0.68;
|
||||
transform: scale(1.04) translateY(-1.4%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moonlit-guard-reveal {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
8%,
|
||||
86% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.guard-nebula,
|
||||
.guard-stars i,
|
||||
.guard-halo {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.jade-guard-scroll {
|
||||
clip-path: none;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-name: jade-guard-copy-fade;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/** Full-viewport renderer for the independently subscribed membership component. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { normalizeGuardEffectSettings } from './api'
|
||||
import { useEffectQueue } from './effectQueue'
|
||||
import { getGuardEffectTheme, guardThemeVariables } from './guardThemes'
|
||||
import { translate, useI18n } from './i18n'
|
||||
import type { ComponentStream } from './stream'
|
||||
import { defaultGuardEffectSettings } from './types'
|
||||
import type { GuardEffectSettings } from './types'
|
||||
import { typographyVariables } from './typography'
|
||||
|
||||
type Viewer = { uid?: string; name?: string }
|
||||
type GuardPayload = {
|
||||
viewer?: Viewer
|
||||
guardName?: string
|
||||
quantity?: number
|
||||
price?: number
|
||||
settings?: Partial<GuardEffectSettings>
|
||||
}
|
||||
type GuardEnvelope = { id: string; type: string; payload?: GuardPayload }
|
||||
type GuardEffect = {
|
||||
id: string
|
||||
payload: GuardPayload
|
||||
}
|
||||
type GuardLevel = 'captain' | 'admiral' | 'governor'
|
||||
export type GuardEffectPreviewMode = GuardLevel
|
||||
|
||||
const JADE_VIDEO_DURATION_MS = 8_000
|
||||
|
||||
function guardLevel(guardName: string | undefined): GuardLevel {
|
||||
const normalized = guardName?.trim().toLocaleLowerCase() ?? ''
|
||||
if (normalized.includes('总督') || normalized.includes('governor')) return 'governor'
|
||||
if (normalized.includes('提督') || normalized.includes('admiral')) return 'admiral'
|
||||
return 'captain'
|
||||
}
|
||||
|
||||
function guardVideoUrl(level: GuardLevel): string {
|
||||
return `/assets/${level === 'governor' ? 'general' : level}.webm`
|
||||
}
|
||||
|
||||
function previewEnvelope(level: GuardLevel, nonce: number): GuardEnvelope {
|
||||
return {
|
||||
id: `preview-${level}-${nonce}`,
|
||||
type: 'live.guard.buy',
|
||||
payload: {
|
||||
viewer: { uid: '10001', name: translate('gift.preview.viewer') },
|
||||
guardName: translate(`gift_menu.guard.${level}`),
|
||||
quantity: 1,
|
||||
price: 198_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toGuardEffect(envelope: GuardEnvelope): GuardEffect | undefined {
|
||||
if (envelope.type !== 'live.guard.buy') return undefined
|
||||
return {
|
||||
id: envelope.id,
|
||||
payload: envelope.payload ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
function useGuardEffect(
|
||||
preview: boolean,
|
||||
stream: ComponentStream | undefined,
|
||||
previewSettings: GuardEffectSettings | undefined,
|
||||
previewMode: GuardEffectPreviewMode,
|
||||
previewNonce: number,
|
||||
language: string,
|
||||
) {
|
||||
const [settings, setSettings] = useState(defaultGuardEffectSettings)
|
||||
const queue = useEffectQueue<GuardEffect>()
|
||||
const settingsRef = useRef(settings)
|
||||
const lastSequenceRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (preview || !stream) return
|
||||
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
|
||||
for (const message of pending) {
|
||||
lastSequenceRef.current = message.sequence
|
||||
const envelope = message.envelope as GuardEnvelope
|
||||
if (
|
||||
envelope.type === 'component.settings.snapshot' ||
|
||||
envelope.type === 'component.settings.updated'
|
||||
) {
|
||||
const next = normalizeGuardEffectSettings(envelope.payload?.settings)
|
||||
settingsRef.current = next
|
||||
setSettings(next)
|
||||
continue
|
||||
}
|
||||
const next = toGuardEffect(envelope)
|
||||
if (next) queue.enqueue(next, settingsRef.current.queueCapacity)
|
||||
}
|
||||
}, [preview, stream, stream?.messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) return
|
||||
const currentSettings = previewSettings ?? settingsRef.current
|
||||
const effect = toGuardEffect(previewEnvelope(previewMode, previewNonce))
|
||||
if (effect) queue.enqueue(effect, currentSettings.queueCapacity)
|
||||
}, [
|
||||
language,
|
||||
preview,
|
||||
previewMode,
|
||||
previewNonce,
|
||||
previewSettings?.effectDurationMs,
|
||||
previewSettings?.themeId,
|
||||
])
|
||||
|
||||
return {
|
||||
settings,
|
||||
effect: queue.active,
|
||||
pendingCount: queue.pendingCount,
|
||||
completeEffect: queue.complete,
|
||||
}
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
let result = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
result ^= value.charCodeAt(index)
|
||||
result = Math.imul(result, 16777619)
|
||||
}
|
||||
return result >>> 0
|
||||
}
|
||||
|
||||
function JadeGuardVoyage({
|
||||
effect,
|
||||
settings,
|
||||
muted,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: GuardEffect
|
||||
settings: GuardEffectSettings
|
||||
muted: boolean
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const level = guardLevel(effect.payload.guardName)
|
||||
const guard = translate(`gift_menu.guard.${level}`)
|
||||
const viewer =
|
||||
effect.payload.viewer?.uid || effect.payload.viewer?.name || translate('common.viewer')
|
||||
const title = settings.titleTemplate.replaceAll('{guard}', guard)
|
||||
return (
|
||||
<section
|
||||
className={`jade-guard-voyage guard-level-${level}`}
|
||||
aria-label={translate('guard.effect_aria')}
|
||||
style={{ ['--guard-duration' as string]: `${JADE_VIDEO_DURATION_MS}ms` } as CSSProperties}
|
||||
>
|
||||
<video
|
||||
autoPlay
|
||||
playsInline
|
||||
preload="auto"
|
||||
muted={muted}
|
||||
src={guardVideoUrl(level)}
|
||||
key={`${effect.id}:${level}`}
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={event => {
|
||||
if (event.animationName === 'jade-guard-video-fade') onComplete()
|
||||
}}
|
||||
/>
|
||||
<div className="jade-guard-scroll" aria-live="polite">
|
||||
<strong>{title}</strong>
|
||||
<b>{viewer}</b>
|
||||
<span>{settings.closingText}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonlitGuardCelebration({
|
||||
effect,
|
||||
settings,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: GuardEffect
|
||||
settings: GuardEffectSettings
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const count = settings.lowPerformanceMode ? Math.min(24, settings.starCount) : settings.starCount
|
||||
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||
const guard = effect.payload.guardName || translate('common.guard')
|
||||
return (
|
||||
<section
|
||||
className="guard-celebration"
|
||||
aria-label={translate('guard.effect_aria')}
|
||||
style={{ ['--guard-duration' as string]: `${settings.effectDurationMs}ms` } as CSSProperties}
|
||||
onAnimationEnd={event => {
|
||||
if (event.target === event.currentTarget && event.animationName === 'moonlit-guard-reveal')
|
||||
onComplete()
|
||||
}}
|
||||
>
|
||||
<div className="guard-nebula" />
|
||||
<div className="guard-stars" aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const seed = hash(`${effect.id}:guard:${index}`)
|
||||
return (
|
||||
<i
|
||||
key={index}
|
||||
style={
|
||||
{
|
||||
left: `${seed % 100}%`,
|
||||
top: `${(seed >>> 8) % 100}%`,
|
||||
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
|
||||
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="guard-halo" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<div className="guard-copy">
|
||||
<span>{translate('guard.salute')}</span>
|
||||
<strong>{translate('guard.title', { guard })}</strong>
|
||||
<b>{translate('guard.viewer', { viewer })}</b>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function GuardEffectOverlay({
|
||||
preview = false,
|
||||
previewSettings,
|
||||
previewMode = 'captain',
|
||||
previewNonce = 0,
|
||||
stream,
|
||||
}: {
|
||||
preview?: boolean
|
||||
previewSettings?: GuardEffectSettings
|
||||
previewMode?: GuardEffectPreviewMode
|
||||
previewNonce?: number
|
||||
stream?: ComponentStream
|
||||
}) {
|
||||
const { language } = useI18n()
|
||||
const remote = useGuardEffect(
|
||||
preview,
|
||||
stream,
|
||||
previewSettings,
|
||||
previewMode,
|
||||
previewNonce,
|
||||
language,
|
||||
)
|
||||
const settings = previewSettings ?? remote.settings
|
||||
const theme = getGuardEffectTheme(settings.themeId)
|
||||
const effect = remote.effect
|
||||
const onComplete = effect ? () => remote.completeEffect(effect.id) : undefined
|
||||
return (
|
||||
<main
|
||||
className={`guard-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'guard-low-motion' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
data-queue-length={remote.pendingCount}
|
||||
style={{
|
||||
...guardThemeVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
}}
|
||||
>
|
||||
{effect &&
|
||||
onComplete &&
|
||||
(theme.id === 'moonlit-water' ? (
|
||||
<MoonlitGuardCelebration
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
settings={settings}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
) : (
|
||||
<JadeGuardVoyage
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
settings={settings}
|
||||
muted={preview}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { GuardEffectThemeId } from './types'
|
||||
|
||||
export type GuardEffectTheme = {
|
||||
id: GuardEffectThemeId
|
||||
nameKey: string
|
||||
descriptionKey: string
|
||||
className: string
|
||||
palette: {
|
||||
cyan: string
|
||||
gold: string
|
||||
rose: string
|
||||
night: string
|
||||
}
|
||||
motion: {
|
||||
reveal: string
|
||||
starPulse: string
|
||||
}
|
||||
}
|
||||
|
||||
export const guardEffectThemes: readonly GuardEffectTheme[] = [
|
||||
{
|
||||
id: 'jade-starfall',
|
||||
nameKey: 'guard.theme.jade_starfall.name',
|
||||
descriptionKey: 'guard.theme.jade_starfall.description',
|
||||
className: 'guard-theme-jade-starfall',
|
||||
palette: {
|
||||
cyan: '#a9fff2',
|
||||
gold: '#ffe7a4',
|
||||
rose: '#ffd0e5',
|
||||
night: '#020c18',
|
||||
},
|
||||
motion: {
|
||||
reveal: 'guard-effect-reveal',
|
||||
starPulse: 'guard-star-pulse',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moonlit-water',
|
||||
nameKey: 'guard.theme.moonlit_water.name',
|
||||
descriptionKey: 'guard.theme.moonlit_water.description',
|
||||
className: 'guard-theme-moonlit-water',
|
||||
palette: {
|
||||
cyan: '#c6d8c6',
|
||||
gold: '#e7c982',
|
||||
rose: '#dbc6a0',
|
||||
night: 'transparent',
|
||||
},
|
||||
motion: {
|
||||
reveal: 'moonlit-guard-reveal',
|
||||
starPulse: 'guard-star-pulse',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function getGuardEffectTheme(id: unknown): GuardEffectTheme {
|
||||
return guardEffectThemes.find(theme => theme.id === id) ?? guardEffectThemes[0]
|
||||
}
|
||||
|
||||
export function normalizeGuardEffectThemeId(id: unknown): GuardEffectThemeId {
|
||||
return getGuardEffectTheme(id).id
|
||||
}
|
||||
|
||||
export function guardThemeVariables(theme: GuardEffectTheme): CSSProperties {
|
||||
return {
|
||||
['--guard-cyan' as string]: theme.palette.cyan,
|
||||
['--guard-gold' as string]: theme.palette.gold,
|
||||
['--guard-rose' as string]: theme.palette.rose,
|
||||
['--guard-night' as string]: theme.palette.night,
|
||||
['--guard-motion-reveal' as string]: theme.motion.reveal,
|
||||
['--guard-motion-star' as string]: theme.motion.starPulse,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SongRequestsPage,
|
||||
} 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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -73,8 +73,7 @@ body,
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song-current-copy,
|
||||
.song-score {
|
||||
.song-current-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
@@ -83,15 +82,13 @@ body,
|
||||
}
|
||||
|
||||
.song-current-copy,
|
||||
.song-score,
|
||||
.song-queue > header,
|
||||
.song-row,
|
||||
.song-queue-empty {
|
||||
filter: brightness(var(--component-font-brightness, 1.3));
|
||||
}
|
||||
|
||||
.song-current-copy small,
|
||||
.song-score small {
|
||||
.song-current-copy small {
|
||||
color: var(--component-song-requester-color, var(--theme-compact-user));
|
||||
font-size: 0.68em;
|
||||
}
|
||||
@@ -104,16 +101,6 @@ body,
|
||||
text-shadow: 0 0 20px rgba(133, 255, 232, 0.2);
|
||||
}
|
||||
|
||||
.song-score {
|
||||
justify-items: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.song-score b {
|
||||
font-size: 0.92em;
|
||||
color: var(--theme-price);
|
||||
}
|
||||
|
||||
.song-queue {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
@@ -359,13 +346,6 @@ body,
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.song-overlay.song-narrow .song-score {
|
||||
grid-column: 2;
|
||||
grid-template-columns: auto auto;
|
||||
justify-items: start;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.song-overlay.song-short .song-current {
|
||||
min-height: 42px;
|
||||
padding-block: 4px;
|
||||
@@ -458,14 +438,12 @@ body,
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-current-copy,
|
||||
.theme-moonlit-water .song-score {
|
||||
.theme-moonlit-water .song-current-copy {
|
||||
gap: 0.18em;
|
||||
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-current-copy small,
|
||||
.theme-moonlit-water .song-score small,
|
||||
.theme-moonlit-water .song-requester,
|
||||
.theme-moonlit-water .song-queue > header {
|
||||
color: var(--component-song-requester-color, #a9b79b);
|
||||
@@ -480,27 +458,10 @@ body,
|
||||
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-score b,
|
||||
.theme-moonlit-water .song-index {
|
||||
color: #c9b06f;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-score {
|
||||
min-width: clamp(72px, 6.8em, 116px);
|
||||
justify-items: end;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-score b {
|
||||
font-size: 0.68em;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-score small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-waveform {
|
||||
display: flex;
|
||||
height: 0.8em;
|
||||
@@ -634,13 +595,6 @@ body,
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.song-overlay.theme-moonlit-water.song-narrow .song-score {
|
||||
grid-column: auto;
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 4.8em;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.song-overlay.theme-moonlit-water.song-short {
|
||||
gap: 6px;
|
||||
padding-block: 6px;
|
||||
|
||||
@@ -39,8 +39,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
|
||||
queuePosition: 0,
|
||||
requestedAt: now,
|
||||
startedAt: now,
|
||||
averageScore: 4.8,
|
||||
ratingCount: 26,
|
||||
},
|
||||
queued: titles.map((title, index) => ({
|
||||
id: `preview-${index}`,
|
||||
@@ -49,7 +47,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
|
||||
status: 'queued',
|
||||
queuePosition: index + 1,
|
||||
requestedAt: now,
|
||||
ratingCount: 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -199,14 +196,6 @@ function useSongQueue(preview: boolean, language: string, stream?: ComponentStre
|
||||
return { settings, queue }
|
||||
}
|
||||
|
||||
function score(item?: SongRequestItem) {
|
||||
if (!item?.ratingCount || item.averageScore == null) return translate('song.no_rating')
|
||||
return translate('song.overlay.score', {
|
||||
score: item.averageScore.toFixed(1),
|
||||
count: item.ratingCount,
|
||||
})
|
||||
}
|
||||
|
||||
/** Theme-owned stars and florets used inside the compact current-song card. */
|
||||
function SongParticles({ theme, count = 8 }: { theme: OverlayThemeDefinition; count?: number }) {
|
||||
return (
|
||||
@@ -358,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
@@ -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 = {
|
||||
|
||||
@@ -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 高亮投影 |
|
||||
|
||||
## 重要不变量
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::{
|
||||
db::{Db, DbError},
|
||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||
guard_effect::{GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings},
|
||||
i18n,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
@@ -669,6 +670,23 @@ impl AuthService {
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let guard_component_id = Uuid::new_v4();
|
||||
let guard_settings = serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable");
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&guard_component_id,
|
||||
&user_id,
|
||||
&GUARD_EFFECT_KIND,
|
||||
&GUARD_EFFECT_NAME,
|
||||
&guard_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let menu_component_id = Uuid::new_v4();
|
||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||
.expect("GiftMenuSettings is always JSON serializable");
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||
gift_effect::GiftEffectDefinition,
|
||||
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
|
||||
guard_effect::GuardEffectDefinition,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SongRequestDefinition, SongRequestProjection},
|
||||
};
|
||||
@@ -389,6 +390,12 @@ impl ComponentRegistry {
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.register(
|
||||
Arc::new(GuardEffectDefinition),
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
|
||||
.expect("built-in component kinds are unique");
|
||||
@@ -560,7 +567,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
|
||||
fn builtin_gift_effect_only_subscribes_to_gifts() {
|
||||
let registry = ComponentRegistry::default();
|
||||
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
|
||||
let runtime = registry.runtime("gift_effect").unwrap();
|
||||
@@ -574,7 +581,26 @@ mod tests {
|
||||
);
|
||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_guard_effect_only_subscribes_to_guard_purchases() {
|
||||
let registry = ComponentRegistry::default();
|
||||
assert!(registry.kinds().contains(&"guard_effect".to_owned()));
|
||||
let runtime = registry.runtime("guard_effect").unwrap();
|
||||
let instance = ComponentInstance::new(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
"guard_effect",
|
||||
"大航海特效",
|
||||
1,
|
||||
runtime.definition().default_settings(),
|
||||
);
|
||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ struct OverlayFileConfig {
|
||||
font_scale: Option<u16>,
|
||||
decoration_line_weight: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
expand_new_danmaku: Option<bool>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
unfold_duration_ms: Option<u16>,
|
||||
motion_intensity: Option<u8>,
|
||||
@@ -345,6 +346,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
show_like: file.events.like.unwrap_or(default.show_like),
|
||||
show_share: file.events.share.unwrap_or(default.show_share),
|
||||
max_visible: file.max_visible.unwrap_or(default.max_visible),
|
||||
expand_new_danmaku: file
|
||||
.expand_new_danmaku
|
||||
.unwrap_or(default.expand_new_danmaku),
|
||||
collapse_after_seconds: file
|
||||
.collapse_after_seconds
|
||||
.unwrap_or(default.collapse_after_seconds),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Full-screen gift and membership effect component.
|
||||
//! Full-screen gift effect component.
|
||||
//!
|
||||
//! The component is deliberately passive: it subscribes to canonical gift and
|
||||
//! guard-purchase events and projects them to its own authenticated OBS stream.
|
||||
//! The component is deliberately passive: it subscribes only to canonical gift
|
||||
//! events and projects them to its own authenticated OBS stream.
|
||||
//! All visual differentiation is settings-driven in the browser, so receiving
|
||||
//! an effect never creates database writes or depends on an OBS connection.
|
||||
|
||||
@@ -57,9 +57,8 @@ pub struct GiftEffectSettings {
|
||||
pub high: MeteorTierSettings,
|
||||
pub featured: MeteorTierSettings,
|
||||
pub trail_intensity: u8,
|
||||
pub guard_star_count: u8,
|
||||
pub guard_effect_duration_ms: u16,
|
||||
pub max_concurrent_effects: u8,
|
||||
#[serde(default = "default_queue_capacity")]
|
||||
pub queue_capacity: u16,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
|
||||
@@ -87,9 +86,7 @@ impl Default for GiftEffectSettings {
|
||||
speed: 880,
|
||||
},
|
||||
trail_intensity: 78,
|
||||
guard_star_count: 48,
|
||||
guard_effect_duration_ms: 5_200,
|
||||
max_concurrent_effects: 8,
|
||||
queue_capacity: default_queue_capacity(),
|
||||
low_performance_mode: false,
|
||||
}
|
||||
}
|
||||
@@ -105,13 +102,15 @@ impl GiftEffectSettings {
|
||||
self.high = self.high.sanitize();
|
||||
self.featured = self.featured.sanitize();
|
||||
self.trail_intensity = self.trail_intensity.min(100);
|
||||
self.guard_star_count = self.guard_star_count.clamp(8, 96);
|
||||
self.guard_effect_duration_ms = self.guard_effect_duration_ms.clamp(1_000, 15_000);
|
||||
self.max_concurrent_effects = self.max_concurrent_effects.clamp(1, 12);
|
||||
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_queue_capacity() -> u16 {
|
||||
256
|
||||
}
|
||||
|
||||
pub struct GiftEffectDefinition;
|
||||
|
||||
impl GiftEffectDefinition {
|
||||
@@ -148,10 +147,7 @@ impl ComponentDefinition for GiftEffectDefinition {
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
self.parse(settings.clone())?;
|
||||
Ok(EventSubscription::new([
|
||||
LiveEventKind::Gift,
|
||||
LiveEventKind::GuardPurchase,
|
||||
]))
|
||||
Ok(EventSubscription::new([LiveEventKind::Gift]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,25 +164,38 @@ mod tests {
|
||||
settings["featured"]["size"] = Value::from(9_999);
|
||||
settings["highValueThreshold"] = Value::from(50_000);
|
||||
settings["featuredValueThreshold"] = Value::from(10_000);
|
||||
settings["queueCapacity"] = Value::from(9_999);
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["normal"]["count"], 1);
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["featured"]["size"], 480);
|
||||
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
|
||||
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_only_subscribes_to_durable_gifts_and_guards() {
|
||||
fn component_only_subscribes_to_durable_gifts() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let subscriptions = definition
|
||||
.subscriptions(&definition.default_settings())
|
||||
.unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_receive_queue_capacity_default() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings.as_object_mut().unwrap().remove("queueCapacity");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["queueCapacity"], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GiftEffectDefinition;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Full-screen membership-purchase effect component.
|
||||
//!
|
||||
//! This component is deliberately separate from gift effects: every instance
|
||||
//! owns its settings, read-only OBS token, event subscription, and WebSocket
|
||||
//! channel, and consumes only canonical guard-purchase events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
components::{ComponentDefinition, ComponentError, EventSubscription},
|
||||
domain::LiveEventKind,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const GUARD_EFFECT_KIND: &str = "guard_effect";
|
||||
pub const GUARD_EFFECT_NAME: &str = "大航海特效";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GuardEffectThemeId {
|
||||
#[default]
|
||||
JadeStarfall,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GuardEffectSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: GuardEffectThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
pub star_count: u8,
|
||||
pub effect_duration_ms: u16,
|
||||
#[serde(default = "default_title_template")]
|
||||
pub title_template: String,
|
||||
#[serde(default = "default_closing_text")]
|
||||
pub closing_text: String,
|
||||
#[serde(default = "default_queue_capacity")]
|
||||
pub queue_capacity: u16,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for GuardEffectSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: GuardEffectThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
star_count: 48,
|
||||
effect_duration_ms: 5_200,
|
||||
title_template: default_title_template(),
|
||||
closing_text: default_closing_text(),
|
||||
queue_capacity: default_queue_capacity(),
|
||||
low_performance_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GuardEffectSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.star_count = self.star_count.clamp(8, 96);
|
||||
self.effect_duration_ms = self.effect_duration_ms.clamp(1_000, 15_000);
|
||||
self.title_template = sanitize_copy(self.title_template, &default_title_template());
|
||||
self.closing_text = sanitize_copy(self.closing_text, &default_closing_text());
|
||||
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_queue_capacity() -> u16 {
|
||||
256
|
||||
}
|
||||
|
||||
/// Preserve the membership-related portion of the first legacy gift-effect
|
||||
/// instance when an existing account receives its initial guard component.
|
||||
pub fn settings_from_legacy_gift(value: &Value) -> GuardEffectSettings {
|
||||
let mut migrated = serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable");
|
||||
for (legacy, current) in [
|
||||
("themeId", "themeId"),
|
||||
("fontFamily", "fontFamily"),
|
||||
("fontBrightness", "fontBrightness"),
|
||||
("guardStarCount", "starCount"),
|
||||
("guardEffectDurationMs", "effectDurationMs"),
|
||||
("guardTitleTemplate", "titleTemplate"),
|
||||
("guardClosingText", "closingText"),
|
||||
("lowPerformanceMode", "lowPerformanceMode"),
|
||||
] {
|
||||
if let Some(candidate) = value.get(legacy) {
|
||||
migrated[current] = candidate.clone();
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<GuardEffectSettings>(migrated)
|
||||
.unwrap_or_default()
|
||||
.sanitize()
|
||||
}
|
||||
|
||||
fn default_title_template() -> String {
|
||||
"{guard}启航".into()
|
||||
}
|
||||
|
||||
fn default_closing_text() -> String {
|
||||
"相伴前行".into()
|
||||
}
|
||||
|
||||
fn sanitize_copy(value: String, fallback: &str) -> String {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
fallback.to_owned()
|
||||
} else {
|
||||
normalized.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GuardEffectDefinition;
|
||||
|
||||
impl GuardEffectDefinition {
|
||||
fn parse(&self, settings: Value) -> Result<GuardEffectSettings, ComponentError> {
|
||||
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentDefinition for GuardEffectDefinition {
|
||||
fn kind(&self) -> &'static str {
|
||||
GUARD_EFFECT_KIND
|
||||
}
|
||||
|
||||
fn settings_version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_settings(&self) -> Value {
|
||||
serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable")
|
||||
}
|
||||
|
||||
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
|
||||
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
|
||||
ComponentError::InvalidSettings {
|
||||
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
self.parse(settings.clone())?;
|
||||
Ok(EventSubscription::new([LiveEventKind::GuardPurchase]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_are_bounded_and_copy_is_sanitized() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["fontBrightness"] = Value::from(999);
|
||||
settings["starCount"] = Value::from(255);
|
||||
settings["effectDurationMs"] = Value::from(200);
|
||||
settings["queueCapacity"] = Value::from(9_999);
|
||||
settings["titleTemplate"] = Value::from(format!(" {{guard}}{} ", "启航".repeat(50)));
|
||||
settings["closingText"] = Value::from(" ");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["starCount"], 96);
|
||||
assert_eq!(sanitized["effectDurationMs"], 1_000);
|
||||
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||
assert_eq!(
|
||||
sanitized["titleTemplate"].as_str().unwrap().chars().count(),
|
||||
80
|
||||
);
|
||||
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_receive_copy_defaults() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings.as_object_mut().unwrap().remove("titleTemplate");
|
||||
settings.as_object_mut().unwrap().remove("closingText");
|
||||
settings.as_object_mut().unwrap().remove("queueCapacity");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["titleTemplate"], "{guard}启航");
|
||||
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||
assert_eq!(sanitized["queueCapacity"], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_gift_settings_preserve_membership_configuration() {
|
||||
let legacy = serde_json::json!({
|
||||
"themeId": "moonlit-water",
|
||||
"fontFamily": "kai",
|
||||
"fontBrightness": 145,
|
||||
"guardStarCount": 72,
|
||||
"guardEffectDurationMs": 8_500,
|
||||
"guardTitleTemplate": "{guard}出发",
|
||||
"guardClosingText": "一路顺风",
|
||||
"lowPerformanceMode": true,
|
||||
"normal": { "count": 20 }
|
||||
});
|
||||
|
||||
let migrated = settings_from_legacy_gift(&legacy);
|
||||
|
||||
assert_eq!(migrated.theme_id, GuardEffectThemeId::MoonlitWater);
|
||||
assert_eq!(migrated.star_count, 72);
|
||||
assert_eq!(migrated.effect_duration_ms, 8_500);
|
||||
assert_eq!(migrated.title_template, "{guard}出发");
|
||||
assert_eq!(migrated.closing_text, "一路顺风");
|
||||
assert!(migrated.low_performance_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_only_subscribes_to_guard_purchases() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let subscriptions = definition
|
||||
.subscriptions(&definition.default_settings())
|
||||
.unwrap();
|
||||
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["themeId"] = Value::from("moonlit-water");
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod db;
|
||||
pub mod domain;
|
||||
pub mod gift_effect;
|
||||
pub mod gift_menu;
|
||||
pub mod guard_effect;
|
||||
pub mod http_api;
|
||||
pub mod i18n;
|
||||
pub mod live;
|
||||
|
||||
@@ -57,6 +57,8 @@ pub struct OverlaySettings {
|
||||
pub show_like: bool,
|
||||
pub show_share: bool,
|
||||
pub max_visible: u8,
|
||||
#[serde(default = "default_expand_new_danmaku")]
|
||||
pub expand_new_danmaku: bool,
|
||||
pub collapse_after_seconds: u16,
|
||||
#[serde(default = "default_unfold_duration_ms")]
|
||||
pub unfold_duration_ms: u16,
|
||||
@@ -89,6 +91,7 @@ impl Default for OverlaySettings {
|
||||
show_like: false,
|
||||
show_share: false,
|
||||
max_visible: 5,
|
||||
expand_new_danmaku: default_expand_new_danmaku(),
|
||||
collapse_after_seconds: 12,
|
||||
unfold_duration_ms: default_unfold_duration_ms(),
|
||||
motion_intensity: 70,
|
||||
@@ -141,6 +144,10 @@ fn default_unfold_duration_ms() -> u16 {
|
||||
1_000
|
||||
}
|
||||
|
||||
fn default_expand_new_danmaku() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_particle_count() -> u8 {
|
||||
8
|
||||
}
|
||||
@@ -588,6 +595,7 @@ mod tests {
|
||||
danmaku_color: Some("not-css".into()),
|
||||
decoration_line_weight: 999,
|
||||
max_visible: 99,
|
||||
expand_new_danmaku: false,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
motion_intensity: 200,
|
||||
@@ -604,6 +612,7 @@ mod tests {
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.decoration_line_weight, 300);
|
||||
assert_eq!(settings.max_visible, 12);
|
||||
assert!(!settings.expand_new_danmaku);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
assert_eq!(settings.motion_intensity, 100);
|
||||
@@ -630,6 +639,7 @@ mod tests {
|
||||
object.remove("viewerColor");
|
||||
object.remove("danmakuColor");
|
||||
object.remove("decorationLineWeight");
|
||||
object.remove("expandNewDanmaku");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
@@ -642,6 +652,7 @@ mod tests {
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.font_scale, 140);
|
||||
assert_eq!(settings.decoration_line_weight, 160);
|
||||
assert!(settings.expand_new_danmaku);
|
||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
assert_eq!(settings.particle_speed, 100);
|
||||
|
||||
@@ -17,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 \
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user