更新点歌和弹幕微调
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 = {
|
||||
|
||||
Reference in New Issue
Block a user