更新点歌和弹幕微调

This commit is contained in:
2026-08-19 12:30:30 -07:00
parent a36d511d39
commit 845b0f5900
43 changed files with 1903 additions and 766 deletions
+8 -4
View File
@@ -27,8 +27,10 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 |
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
| `src/giftEffect.tsx` | 礼物流星、大航海全屏庆祝与视口自适应渲染 |
| `src/giftEffect.tsx` | 礼物流星与视口自适应渲染 |
| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 |
| `src/guardEffect.tsx` | 独立大航海视频、感谢卷轴与月夜庆祝渲染 |
| `src/guardThemes.ts` | 可扩展大航海特效主题注册表与 CSS 变量 |
| `src/giftMenu.tsx` | 礼物菜单无限循环、触发定位与高亮 reducer |
| `src/giftMenuThemes.ts` | 可扩展礼物菜单主题注册表 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
@@ -60,9 +62,10 @@ metadata 的唯一文案来源。Vite 在构建时解析并注入它,Rust 则
## 字体资源
Noto Serif SC、ZCOOL XiaoWei 与 LXGW
WenKai 由锁定的 Fontsource 依赖提供;漓雨手书则以固定版本的 WOFF2 文件保存在
`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。每套字体的 OFL-1.1 许可证均输出到
`/fonts/licenses/`。
WenKai 由锁定的 Fontsource 依赖提供;漓雨手书与鸿雷行书简体则以固定 WOFF2 文件保存在
`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。前三套 Fontsource 字体与漓雨手书的 OFL-1.1 许可证输出到
`/fonts/licenses/`;鸿雷行书的随附说明不构成开放授权,公开或商业部署前必须确认 Web 嵌入与再分发权利,具体摘要见
`public/fonts/NOTICE.md`。
## 格式化与构建
@@ -83,4 +86,5 @@ YAML 和项目文档。
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
- [点歌姬组件](../../docs/components/song-request.md)
- [全屏礼物特效](../../docs/components/gift-effect.md)
- [大航海特效](../../docs/components/guard-effect.md)
- [礼物菜单组件](../../docs/components/gift-menu.md)
+7
View File
@@ -28,3 +28,10 @@ so OBS never needs to load the source websites.
`moonlit-edge.svg` is an original repository asset. It is a transparent, monochrome mask shared by
the Moonlit Water danmaku, song-request, and gift-menu components.
# Membership voyage videos
`captain.webm`, `admiral.webm`, and `general.webm` are project-owner-supplied 1280×720 VP9/Opus
videos used by the independent Jade Starfall Guard, Admiral, and Governor component respectively.
Each video runs for approximately 7.988 seconds. Their redistribution rights remain the
responsibility of the deployment owner.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
View File
@@ -17,3 +17,17 @@ The original font credits Yuji Kataoka and the Yuji Project Authors for the base
and the LXGW ZhenKai Project Authors for punctuation and symbols, and Yuchen Tian's
[zi2zi-JiT](https://github.com/kaonashi-tyc/zi2zi-JiT) for generated Chinese glyphs. This notice
preserves the zi2zi-JiT attribution required by the upstream font documentation.
## HongLei XingShu Jian
`honglei-xingshu-jian.woff2` is a full-font WOFF2 conversion of the project owner's
`鸿雷行书简体.otf`; it is used only by the independent Jade Starfall membership component.
- Embedded family: `hongleixingshu` / `鸿雷行书简体`
- Original OTF SHA-256: `5081c025bc350339885297550b33d6742cd6467c7db5b340c537d0eb1e357234`
- Bundled WOFF2 SHA-256: `6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d`
- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting
- Coverage retained: 7,012 glyphs
- License: the supplied download notice does not grant an open-source or redistribution license.
Confirm that the deployment owner holds Web embedding and redistribution rights before public or
commercial deployment.
Binary file not shown.
+29 -2
View File
@@ -20,6 +20,7 @@ async function filesBelow(directory, prefix = '') {
const files = await filesBelow(dist)
const expectedFonts = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai']
const liyuFont = 'fonts/liyu-shoushu-v0.107.woff2'
const hongleiFont = 'fonts/honglei-xingshu-jian.woff2'
for (const font of expectedFonts) {
if (!files.some(file => file.startsWith(`assets/${font}-`) && file.endsWith('.woff2'))) {
@@ -40,6 +41,26 @@ const liyuDigest = createHash('sha256')
if (liyuDigest !== 'b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3') {
throw new Error(`Unexpected liyu-shoushu WOFF2 digest: ${liyuDigest}`)
}
if (!files.includes(hongleiFont)) throw new Error('Missing bundled WOFF2 asset for HongLei XingShu')
const hongleiDigest = createHash('sha256')
.update(await readFile(new URL(hongleiFont, dist)))
.digest('hex')
if (hongleiDigest !== '6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d') {
throw new Error(`Unexpected HongLei XingShu WOFF2 digest: ${hongleiDigest}`)
}
const guardVideos = new Map([
['assets/captain.webm', 'e9d2b416a582c4606cec1d613027e941bfc422f3ad12de24e88980908496d759'],
['assets/admiral.webm', '7c504469f638f321768edecb3b5ba25d6826737349f3479c89fb1776d35a8672'],
['assets/general.webm', '869b54a5104e4dfbcc97a6338e8805444e0f0034c6d8075530b2242a2c3436e1'],
])
for (const [video, expectedDigest] of guardVideos) {
if (!files.includes(video)) throw new Error(`Missing bundled membership video: ${video}`)
const digest = createHash('sha256')
.update(await readFile(new URL(video, dist)))
.digest('hex')
if (digest !== expectedDigest) throw new Error(`Unexpected membership video digest: ${video}`)
}
const legacyWoff = files.find(file => file.endsWith('.woff'))
if (legacyWoff) throw new Error(`Unexpected legacy WOFF asset: ${legacyWoff}`)
@@ -50,7 +71,13 @@ const css = (
)
).join('\n')
for (const family of ['Noto Serif SC', 'ZCOOL XiaoWei', 'LXGW WenKai', 'Liyu Shoushu']) {
for (const family of [
'Noto Serif SC',
'ZCOOL XiaoWei',
'LXGW WenKai',
'Liyu Shoushu',
'HongLei XingShu',
]) {
const declarations = [
`font-family:${family}`,
`font-family:${JSON.stringify(family)}`,
@@ -64,4 +91,4 @@ if (!css.includes('font-display:block') || css.includes('font-display:swap')) {
throw new Error('Bundled fonts must block local fallback while loading')
}
console.log('Verified same-origin WOFF2 component fonts and licenses.')
console.log('Verified same-origin component fonts, licenses, and membership videos.')
+14 -6
View File
@@ -11,6 +11,7 @@ import type {
AuthUser,
ComponentSummary,
CookieCloudSource,
GuardEffectSettings,
GiftEffectSettings,
GiftCatalogItem,
GiftMenuItem,
@@ -27,8 +28,10 @@ import { normalizeThemeId } from './themes'
import { normalizeFontFamilyId } from './typography'
import { normalizeGiftEffectThemeId } from './giftThemes'
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
import { normalizeGuardEffectThemeId } from './guardThemes'
import {
defaultGiftEffectSettings,
defaultGuardEffectSettings,
defaultGiftMenuSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
@@ -261,6 +264,17 @@ export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings
}
}
export function normalizeGuardEffectSettings(value: unknown): GuardEffectSettings {
const root = object(value)
const settings = object(root.settings ?? value)
return {
...defaultGuardEffectSettings,
...(settings as Partial<GuardEffectSettings>),
themeId: normalizeGuardEffectThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
}
}
function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined {
const item = object(value)
const trigger = object(item.trigger)
@@ -366,11 +380,6 @@ export function normalizeSongRequestItem(value: unknown): SongRequestItem | unde
typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined,
finishedAt:
typeof item.finishedAt === 'string' || item.finishedAt === null ? item.finishedAt : undefined,
averageScore:
typeof item.averageScore === 'number' || item.averageScore === null
? item.averageScore
: undefined,
ratingCount: Number(item.ratingCount ?? 0),
}
}
@@ -390,7 +399,6 @@ export function normalizeSongRequestPage(value: unknown): SongRequestPage {
queuedCount: Number(summary.queuedCount ?? 0),
completedCount: Number(summary.completedCount ?? 0),
cancelledCount: Number(summary.cancelledCount ?? 0),
ratingCount: Number(summary.ratingCount ?? 0),
},
}
}
+1 -1
View File
@@ -110,7 +110,7 @@
.song-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
+272 -75
View File
@@ -17,6 +17,7 @@ import {
normalizeComponents,
normalizeGiftCatalog,
normalizeGiftEffectSettings,
normalizeGuardEffectSettings,
normalizeGiftMenuSettings,
normalizeInvitations,
normalizeEnrollment,
@@ -29,9 +30,12 @@ import {
import { TotpQr } from './auth'
import { Overlay } from './overlay'
import { GiftEffectOverlay } from './giftEffect'
import { GuardEffectOverlay } from './guardEffect'
import { GiftMenuOverlay } from './giftMenu'
import type { GiftEffectPreviewMode } from './giftEffect'
import type { GuardEffectPreviewMode } from './guardEffect'
import { getGiftEffectTheme, giftEffectThemes } from './giftThemes'
import { getGuardEffectTheme, guardEffectThemes } from './guardThemes'
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemes } from './giftMenuThemes'
import { PwaControls, usePwaUpdateBlocker } from './pwa'
import { SongRequestOverlay } from './songOverlay'
@@ -41,6 +45,7 @@ import { fontFamilies } from './typography'
import type { FontFamilyId } from './typography'
import {
defaultGiftEffectSettings,
defaultGuardEffectSettings,
defaultGiftMenuSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
@@ -51,6 +56,7 @@ import type {
ComponentSummary,
CookieCloudSource,
GiftEffectSettings,
GuardEffectSettings,
GiftCatalogItem,
GiftMenuItem,
GiftMenuSettings,
@@ -201,11 +207,21 @@ function isGiftEffectKind(kind: string): boolean {
return kind === 'gift_effect'
}
function isGuardEffectKind(kind: string): boolean {
return kind === 'guard_effect'
}
function isGiftMenuKind(kind: string): boolean {
return kind === 'gift_menu'
}
const componentKinds = ['danmaku_overlay', 'song_request', 'gift_effect', 'gift_menu'] as const
const componentKinds = [
'danmaku_overlay',
'song_request',
'gift_effect',
'guard_effect',
'gift_menu',
] as const
function componentKindLabel(kind: string): string {
return isDanmakuKind(kind)
@@ -214,9 +230,11 @@ function componentKindLabel(kind: string): string {
? translate('components.song_type')
: isGiftEffectKind(kind)
? translate('components.gift_type')
: isGiftMenuKind(kind)
? translate('components.gift_menu_type')
: kind
: isGuardEffectKind(kind)
? translate('components.guard_type')
: isGiftMenuKind(kind)
? translate('components.gift_menu_type')
: kind
}
function componentKindMark(kind: string): string {
@@ -226,9 +244,11 @@ function componentKindMark(kind: string): string {
? translate('components.song_mark')
: isGiftEffectKind(kind)
? translate('components.gift_mark')
: isGiftMenuKind(kind)
? translate('components.gift_menu_mark')
: translate('components.generic_mark')
: isGuardEffectKind(kind)
? translate('components.guard_mark')
: isGiftMenuKind(kind)
? translate('components.gift_menu_mark')
: translate('components.generic_mark')
}
type Flash = { kind: 'success' | 'error'; text: string } | undefined
@@ -434,6 +454,7 @@ function SettingsEditor({
type="range"
min="2"
max="120"
disabled={!settings.expandNewDanmaku}
value={settings.collapseAfterSeconds}
onChange={event => edit('collapseAfterSeconds', +event.target.value)}
/>
@@ -448,6 +469,7 @@ function SettingsEditor({
min="200"
max="5000"
step="100"
disabled={!settings.expandNewDanmaku}
value={settings.unfoldDurationMs}
onChange={event => edit('unfoldDurationMs', +event.target.value)}
/>
@@ -491,6 +513,18 @@ function SettingsEditor({
</label>
</div>
<fieldset className="toggle-grid compact-toggle-grid">
<legend>{translate('settings.new_danmaku_animation')}</legend>
<label>
<input
type="checkbox"
checked={settings.expandNewDanmaku}
onChange={event => edit('expandNewDanmaku', event.target.checked)}
/>
<span>{translate('settings.expand_new_danmaku')}</span>
</label>
</fieldset>
<fieldset className="toggle-grid">
<legend>{translate('settings.show_events')}</legend>
{eventToggles.map(([key, labelKey]) => (
@@ -878,42 +912,15 @@ function GiftEffectSettingsEditor({
/>
</label>
<label>
<span>
{translate('gift.settings.guard_stars')} <output>{settings.guardStarCount}</output>
</span>
{translate('gift.settings.queue_capacity')}
<input
type="range"
min="8"
max="96"
value={settings.guardStarCount}
onChange={event => edit('guardStarCount', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift.settings.guard_duration')}{' '}
<output>{(settings.guardEffectDurationMs / 1000).toFixed(1)}s</output>
</span>
<input
type="range"
min="1000"
max="15000"
step="250"
value={settings.guardEffectDurationMs}
onChange={event => edit('guardEffectDurationMs', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift.settings.concurrent')} <output>{settings.maxConcurrentEffects}</output>
</span>
<input
type="range"
type="number"
min="1"
max="12"
value={settings.maxConcurrentEffects}
onChange={event => edit('maxConcurrentEffects', +event.target.value)}
max="1000"
value={settings.queueCapacity}
onChange={event => edit('queueCapacity', +event.target.value)}
/>
<small>{translate('settings.queue_capacity_description')}</small>
</label>
</div>
<fieldset className="toggle-grid compact-toggle-grid">
@@ -949,7 +956,7 @@ function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) {
className="preview-panel"
>
<div className="preset-buttons gift-preview-buttons">
{(['normal', 'high', 'featured', 'guard'] as const).map(candidate => (
{(['normal', 'high', 'featured'] as const).map(candidate => (
<button
type="button"
className={candidate === mode ? 'active' : 'secondary'}
@@ -972,6 +979,171 @@ function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) {
)
}
function GuardEffectSettingsEditor({
settings,
onChange,
onSave,
saving,
}: {
settings: GuardEffectSettings
onChange: (settings: GuardEffectSettings) => void
onSave: () => Promise<void>
saving: boolean
}) {
const edit = <K extends keyof GuardEffectSettings>(key: K, value: GuardEffectSettings[K]) =>
onChange({ ...settings, [key]: value })
const theme = getGuardEffectTheme(settings.themeId)
return (
<div className="settings-editor guard-effect-settings">
<div className="field-grid theme-selector">
<label>
{translate('settings.theme')}
<select
value={settings.themeId}
onChange={event =>
edit('themeId', event.target.value as GuardEffectSettings['themeId'])
}
>
{guardEffectThemes.map(candidate => (
<option value={candidate.id} key={candidate.id}>
{translate(candidate.nameKey)}
</option>
))}
</select>
<small>{translate(theme.descriptionKey)}</small>
</label>
</div>
{settings.themeId === 'jade-starfall' ? (
<fieldset className="limit-grid guard-copy-settings">
<legend>{translate('guard.settings.copy')}</legend>
<label>
{translate('guard.settings.title_template')}
<input
required
maxLength={80}
value={settings.titleTemplate}
onChange={event => edit('titleTemplate', event.target.value)}
/>
<small>{translate('guard.settings.title_template_description')}</small>
</label>
<label>
{translate('guard.settings.closing_text')}
<input
required
maxLength={80}
value={settings.closingText}
onChange={event => edit('closingText', event.target.value)}
/>
<small>{translate('guard.settings.closing_text_description')}</small>
</label>
</fieldset>
) : (
<>
<TypographySettingsFields
fontFamily={settings.fontFamily}
fontBrightness={settings.fontBrightness}
onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', value)}
/>
<div className="slider-grid guard-global-settings">
<label>
<span>
{translate('guard.settings.stars')} <output>{settings.starCount}</output>
</span>
<input
type="range"
min="8"
max="96"
value={settings.starCount}
onChange={event => edit('starCount', +event.target.value)}
/>
</label>
<label>
<span>
{translate('guard.settings.duration')}{' '}
<output>{(settings.effectDurationMs / 1000).toFixed(1)}s</output>
</span>
<input
type="range"
min="1000"
max="15000"
step="250"
value={settings.effectDurationMs}
onChange={event => edit('effectDurationMs', +event.target.value)}
/>
</label>
</div>
<fieldset className="toggle-grid compact-toggle-grid">
<label>
<input
type="checkbox"
checked={settings.lowPerformanceMode}
onChange={event => edit('lowPerformanceMode', event.target.checked)}
/>
<span>{translate('settings.low_performance')}</span>
</label>
</fieldset>
</>
)}
<div className="field-grid queue-settings">
<label>
{translate('guard.settings.queue_capacity')}
<input
type="number"
min="1"
max="1000"
value={settings.queueCapacity}
onChange={event => edit('queueCapacity', +event.target.value)}
/>
<small>{translate('settings.queue_capacity_description')}</small>
</label>
</div>
<div className="form-actions align-end">
<button type="button" disabled={saving} onClick={() => void onSave()}>
{saving ? translate('settings.saving') : translate('settings.save_sync')}
</button>
</div>
</div>
)
}
function GuardEffectPreview({ settings }: { settings: GuardEffectSettings }) {
const [mode, setMode] = useState<GuardEffectPreviewMode>('captain')
const [nonce, setNonce] = useState(0)
const trigger = (next: GuardEffectPreviewMode) => {
setMode(next)
setNonce(current => current + 1)
}
return (
<Panel
title={translate('guard.preview.title')}
description={translate('guard.preview.description')}
className="preview-panel"
>
<div className="preset-buttons guard-preview-buttons">
{(['captain', 'admiral', 'governor'] as const).map(candidate => (
<button
type="button"
className={candidate === mode ? 'active' : 'secondary'}
onClick={() => trigger(candidate)}
key={candidate}
>
{translate(`guard.preview.${candidate}_button`)}
</button>
))}
</div>
<div className="gift-preview-viewport">
<GuardEffectOverlay
preview
previewSettings={settings}
previewMode={mode}
previewNonce={nonce}
/>
</div>
</Panel>
)
}
function GiftMenuSettingsEditor({
componentId,
settings,
@@ -1906,14 +2078,12 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) {
function TestEvents({
componentId,
giftOnly = false,
eventKinds,
}: {
componentId: string
giftOnly?: boolean
eventKinds: Array<'danmaku' | 'enter' | 'gift' | 'guard'>
}) {
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(
giftOnly ? 'gift' : 'danmaku',
)
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(eventKinds[0])
const [uid, setUid] = useState('test-viewer')
const [name, setName] = useState(() => translate('test.default_viewer'))
const [text, setText] = useState(() => translate('test.default_text'))
@@ -1966,10 +2136,18 @@ function TestEvents({
<label>
{translate('test.event_type')}
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
{!giftOnly && <option value="danmaku">{translate('settings.event.danmaku')}</option>}
{!giftOnly && <option value="enter">{translate('test.enter')}</option>}
<option value="gift">{translate('settings.event.gift')}</option>
<option value="guard">{translate('test.guard')}</option>
{eventKinds.includes('danmaku') && (
<option value="danmaku">{translate('settings.event.danmaku')}</option>
)}
{eventKinds.includes('enter') && (
<option value="enter">{translate('test.enter')}</option>
)}
{eventKinds.includes('gift') && (
<option value="gift">{translate('settings.event.gift')}</option>
)}
{eventKinds.includes('guard') && (
<option value="guard">{translate('test.guard')}</option>
)}
</select>
</label>
<label>
@@ -2210,12 +2388,17 @@ export function ComponentsPage({
...defaultGiftEffectSettings,
...normalizeGiftEffectSettings(payload),
}
: isGiftMenuKind(component.kind)
: isGuardEffectKind(component.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
...defaultGuardEffectSettings,
...normalizeGuardEffectSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
: isGiftMenuKind(component.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
} catch (reason) {
@@ -2370,12 +2553,17 @@ export function ComponentsPage({
...defaultGiftEffectSettings,
...normalizeGiftEffectSettings(payload),
}
: isGiftMenuKind(selected.kind)
: isGuardEffectKind(selected.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
...defaultGuardEffectSettings,
...normalizeGuardEffectSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
: isGiftMenuKind(selected.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
setFlash({
@@ -2500,6 +2688,21 @@ export function ComponentsPage({
</Panel>
<GiftEffectPreview settings={settings as GiftEffectSettings} />
</>
) : isGuardEffectKind(selected.kind) && settings ? (
<>
<Panel
title={translate('components.guard_settings')}
description={translate('components.guard_description')}
>
<GuardEffectSettingsEditor
settings={settings as GuardEffectSettings}
onChange={next => setSettings(next)}
onSave={saveSettings}
saving={saving}
/>
</Panel>
<GuardEffectPreview settings={settings as GuardEffectSettings} />
</>
) : isGiftMenuKind(selected.kind) && settings ? (
<>
<Panel
@@ -2524,10 +2727,19 @@ export function ComponentsPage({
<ObsAccessPanel component={selected} key={selected.id} />
{(isDanmakuKind(selected.kind) ||
isGiftEffectKind(selected.kind) ||
isGuardEffectKind(selected.kind) ||
isGiftMenuKind(selected.kind)) && (
<TestEvents
componentId={selected.id}
giftOnly={isGiftEffectKind(selected.kind) || isGiftMenuKind(selected.kind)}
eventKinds={
isGiftEffectKind(selected.kind)
? ['gift']
: isGuardEffectKind(selected.kind)
? ['guard']
: isGiftMenuKind(selected.kind)
? ['gift', 'guard']
: ['danmaku', 'enter', 'gift', 'guard']
}
key={`test-${selected.id}`}
/>
)}
@@ -2772,7 +2984,6 @@ export function SongRequestsPage({
[translate('song.stat.active'), summary?.activeCount ?? 0],
[translate('song.stat.completed'), summary?.completedCount ?? 0],
[translate('song.stat.cancelled'), summary?.cancelledCount ?? 0],
[translate('song.stat.ratings'), summary?.ratingCount ?? 0],
].map(([label, value]) => (
<div className="stat-card jade-panel" key={label}>
<small>{label}</small>
@@ -2791,14 +3002,6 @@ export function SongRequestsPage({
{active.current.requester.name} · UID {active.current.requester.uid}
</small>
<h2>{active.current.title}</h2>
<p>
{active.current.ratingCount
? translate('song.average_score', {
score: active.current.averageScore?.toFixed(2) ?? '—',
count: active.current.ratingCount,
})
: translate('song.no_rating')}
</p>
</div>
<div className="form-actions">
<button
@@ -2875,7 +3078,6 @@ export function SongRequestsPage({
<th>{translate('song.column.title')}</th>
<th>{translate('song.column.requester')}</th>
<th>{translate('song.column.result')}</th>
<th>{translate('song.column.rating')}</th>
<th>{translate('song.column.finished')}</th>
</tr>
</thead>
@@ -2897,17 +3099,12 @@ export function SongRequestsPage({
)}
</span>
</td>
<td>
{item.ratingCount
? `${item.averageScore?.toFixed(1)} / 5(${item.ratingCount})`
: '—'}
</td>
<td>{formatDate(item.finishedAt ?? undefined)}</td>
</tr>
))}
{historyPage && historyPage.items.length === 0 && (
<tr>
<td colSpan={5}>
<td colSpan={4}>
<div className="empty-state">{translate('song.no_history')}</div>
</td>
</tr>
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useState } from 'react'
type QueueItem = { id: string }
type EffectQueueState<T extends QueueItem> = {
active?: T
pending: T[]
}
export function enqueueEffect<T extends QueueItem>(
state: EffectQueueState<T>,
effect: T,
capacity: number,
): EffectQueueState<T> {
if (state.active?.id === effect.id || state.pending.some(item => item.id === effect.id)) {
return state
}
if (!state.active) return { active: effect, pending: state.pending }
const boundedCapacity = Number.isFinite(capacity) ? Math.max(1, Math.floor(capacity)) : 1
if (state.pending.length >= boundedCapacity) return state
return { ...state, pending: [...state.pending, effect] }
}
export function completeEffect<T extends QueueItem>(
state: EffectQueueState<T>,
activeId: string,
): EffectQueueState<T> {
if (state.active?.id !== activeId) return state
const [active, ...pending] = state.pending
return { active, pending }
}
export function useEffectQueue<T extends QueueItem>() {
const [state, setState] = useState<EffectQueueState<T>>({ pending: [] })
const enqueue = useCallback((effect: T, capacity: number) => {
setState(current => enqueueEffect(current, effect, capacity))
}, [])
const complete = useCallback((activeId: string) => {
setState(current => completeEffect(current, activeId))
}, [])
return { active: state.active, pendingCount: state.pending.length, enqueue, complete }
}
+8
View File
@@ -5,3 +5,11 @@
font-weight: 400;
src: url('/fonts/liyu-shoushu-v0.107.woff2') format('woff2');
}
@font-face {
font-family: 'HongLei XingShu';
font-style: normal;
font-display: block;
font-weight: 400;
src: url('/fonts/honglei-xingshu-jian.woff2') format('woff2');
}
+14 -237
View File
@@ -18,6 +18,10 @@
overflow: hidden;
}
.meteor-burst {
animation: gift-burst-lifetime var(--burst-duration) linear both;
}
.gift-meteor {
position: absolute;
top: var(--meteor-y);
@@ -156,122 +160,7 @@
animation-delay: -360ms;
}
.guard-celebration {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
overflow: hidden;
place-items: center;
opacity: 0;
color: #edfffa;
background:
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--gift-night);
animation: var(--gift-motion-guard, gift-guard-reveal) var(--guard-duration) ease-in-out both;
}
.guard-nebula {
position: absolute;
inset: -25%;
background: conic-gradient(
from 90deg,
transparent,
rgba(93, 237, 209, 0.18),
transparent 32%,
rgba(255, 207, 230, 0.12),
transparent 68%,
rgba(255, 230, 156, 0.13),
transparent
);
filter: blur(28px);
animation: gift-nebula-turn 9s linear infinite;
}
.guard-stars {
position: absolute;
inset: 0;
}
.guard-stars i {
position: absolute;
width: var(--star-size);
height: var(--star-size);
opacity: 0.1;
background: linear-gradient(135deg, #fff8ca, var(--gift-cyan) 58%, var(--gift-rose));
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
filter: drop-shadow(0 0 6px var(--gift-cyan));
animation: var(--gift-motion-star, gift-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
}
.guard-halo {
position: absolute;
width: min(62vmin, 720px);
aspect-ratio: 1;
border: 1px solid rgba(155, 255, 235, 0.4);
border-radius: 50%;
box-shadow:
0 0 60px rgba(87, 241, 212, 0.25),
inset 0 0 70px rgba(255, 224, 166, 0.12);
animation: gift-halo-breathe 2.6s ease-in-out infinite;
}
.guard-halo i {
position: absolute;
inset: 7%;
border: 1px solid rgba(255, 227, 170, 0.38);
border-radius: 45% 55% 48% 52%;
transform: rotate(30deg);
}
.guard-halo i:nth-child(2) {
inset: 15%;
border-color: rgba(255, 195, 224, 0.32);
transform: rotate(76deg);
}
.guard-halo i:nth-child(3) {
inset: 23%;
border-color: rgba(123, 246, 224, 0.45);
transform: rotate(122deg);
}
.guard-copy {
position: relative;
z-index: 3;
display: grid;
max-width: min(82vw, 1000px);
justify-items: center;
gap: clamp(8px, 1.5vh, 20px);
text-align: center;
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
}
.guard-copy span {
color: var(--gift-gold);
font-size: clamp(13px, 1.6vw, 30px);
letter-spacing: 0.45em;
}
.guard-copy strong {
color: #f3fffc;
font-size: clamp(38px, 7vw, 132px);
font-weight: 500;
letter-spacing: 0.1em;
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
}
.guard-copy b {
color: var(--gift-rose);
font-size: clamp(16px, 2.3vw, 44px);
font-weight: 500;
letter-spacing: 0.12em;
}
.gift-low-motion .meteor-spark,
.gift-low-motion .guard-nebula,
.gift-low-motion .guard-halo {
.gift-low-motion .meteor-spark {
animation: none;
}
@@ -293,6 +182,13 @@
}
}
@keyframes gift-burst-lifetime {
from,
to {
visibility: visible;
}
}
@keyframes gift-meteor-sparkle {
from {
opacity: 0.25;
@@ -304,53 +200,12 @@
}
}
@keyframes gift-guard-reveal {
0%,
100% {
opacity: 0;
}
8%,
86% {
opacity: 1;
}
}
@keyframes gift-star-pulse {
0%,
100% {
opacity: 0.08;
transform: rotate(0) scale(0.45);
}
48% {
opacity: 1;
transform: rotate(50deg) scale(1.32);
}
}
@keyframes gift-nebula-turn {
to {
transform: rotate(360deg);
}
}
@keyframes gift-halo-breathe {
50% {
transform: scale(1.08) rotate(3deg);
box-shadow:
0 0 110px rgba(87, 241, 212, 0.38),
inset 0 0 90px rgba(255, 224, 166, 0.2);
}
}
@media (prefers-reduced-motion: reduce) {
.gift-meteor {
animation-duration: max(var(--meteor-duration), 6s);
}
.meteor-spark,
.guard-nebula,
.guard-stars i,
.guard-halo {
.meteor-spark {
animation: none;
}
}
@@ -369,8 +224,7 @@
.moonlit-whisper-copy,
.moonlit-gift-whisper > small,
.moonlit-ceremony-copy,
.guard-copy {
.moonlit-ceremony-copy {
filter: brightness(var(--component-font-brightness, 1.3));
}
@@ -617,76 +471,6 @@
animation: moonlit-image-halo 2.8s ease-in-out infinite;
}
/* Reuse the membership DOM but make its backdrop transparent and turn the
generic nebula into a quiet, full-scene moon-and-water celebration. */
.gift-theme-moonlit-water .guard-celebration {
color: #eadfc3;
background: transparent;
font-family: inherit;
}
.gift-theme-moonlit-water .guard-celebration::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: min(62vmin, 780px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(246, 237, 207, 0.82) 0 42%,
rgba(226, 224, 193, 0.2) 63%,
transparent 71%
);
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
transform: translate(-50%, -50%);
}
.gift-theme-moonlit-water .guard-nebula {
background:
repeating-radial-gradient(
ellipse at 50% 80%,
transparent 0 7%,
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
transparent 7.7% 12%
),
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
filter: blur(1px);
animation: moonlit-guard-water 7s ease-in-out infinite;
}
.gift-theme-moonlit-water .guard-stars i {
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
}
.gift-theme-moonlit-water .guard-halo {
width: min(77vmin, 930px);
border-color: rgba(231, 201, 130, 0.42);
box-shadow:
0 0 44px rgba(231, 201, 130, 0.18),
inset 0 0 65px rgba(182, 207, 180, 0.1);
}
.gift-theme-moonlit-water .guard-halo i {
border-color: rgba(203, 219, 184, 0.36);
}
.gift-theme-moonlit-water .guard-copy {
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
}
.gift-theme-moonlit-water .guard-copy span,
.gift-theme-moonlit-water .guard-copy b {
color: #e7c982;
}
.gift-theme-moonlit-water .guard-copy strong {
color: #eadfc3;
filter: none;
}
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-moon,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-water i,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-stars,
@@ -784,10 +568,3 @@
transform: scale(1.15);
}
}
@keyframes moonlit-guard-water {
50% {
opacity: 0.68;
transform: scale(1.04) translateY(-1.4%);
}
}
+108 -142
View File
@@ -1,7 +1,8 @@
/** Full-viewport gift meteor and guard celebration renderer. */
/** Full-viewport gift renderer. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGiftEffectSettings } from './api'
import { useEffectQueue } from './effectQueue'
import { getGiftEffectTheme, giftThemeVariables } from './giftThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
@@ -21,22 +22,17 @@ type EffectPayload = {
viewer?: Viewer
gift?: Gift
quantity?: number
guardName?: string
price?: number
settings?: Partial<GiftEffectSettings>
}
type EffectEnvelope = { id: string; type: string; payload?: EffectPayload }
type VisualEffect = {
id: string
kind: 'gift' | 'guard'
payload: EffectPayload
receivedAt: number
expiresAt: number
/** Preview cards force a tier so custom thresholds do not change the selected demo. */
previewTier?: GiftTier
}
type GiftTier = 'normal' | 'high' | 'featured'
export type GiftEffectPreviewMode = GiftTier | 'guard'
export type GiftEffectPreviewMode = GiftTier
/** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */
const MOONLIT_CEREMONY_THRESHOLD = 50_000
@@ -59,14 +55,7 @@ function tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier {
}
function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnvelope {
const viewer = { uid: 'preview', name: translate('gift.preview.viewer') }
if (mode === 'guard') {
return {
id: `preview-guard-${nonce}`,
type: 'live.guard.buy',
payload: { viewer, guardName: translate('gift.preview.guard'), quantity: 1, price: 198_000 },
}
}
const viewer = { uid: '10001', name: translate('gift.preview.viewer') }
const totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000
return {
id: `preview-${mode}-${nonce}`,
@@ -86,23 +75,12 @@ function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnve
function toVisualEffect(
envelope: EffectEnvelope,
guardDuration: number,
previewTier?: GiftTier,
): VisualEffect | undefined {
const kind =
envelope.type === 'live.gift'
? 'gift'
: envelope.type === 'live.guard.buy'
? 'guard'
: undefined
if (!kind) return undefined
const now = Date.now()
if (envelope.type !== 'live.gift') return undefined
return {
id: envelope.id,
kind,
payload: envelope.payload ?? {},
receivedAt: now,
expiresAt: now + (kind === 'guard' ? guardDuration + 1_500 : 45_000),
previewTier,
}
}
@@ -116,7 +94,7 @@ function useGiftEffects(
language: string,
) {
const [settings, setSettings] = useState(defaultGiftEffectSettings)
const [effects, setEffects] = useState<VisualEffect[]>([])
const queue = useEffectQueue<VisualEffect>()
const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
@@ -139,36 +117,24 @@ function useGiftEffects(
setSettings(next)
continue
}
const effect = toVisualEffect(envelope, settingsRef.current.guardEffectDurationMs)
const effect = toVisualEffect(envelope)
if (!effect) continue
setEffects(current =>
[effect, ...current.filter(item => item.id !== effect.id)].slice(
0,
settingsRef.current.maxConcurrentEffects,
),
)
queue.enqueue(effect, settingsRef.current.queueCapacity)
}
}, [preview, stream, stream?.messages])
useEffect(() => {
if (!preview) return
const effect = toVisualEffect(
previewEnvelope(previewMode, previewNonce),
previewSettings?.guardEffectDurationMs ?? settingsRef.current.guardEffectDurationMs,
previewMode === 'guard' ? undefined : previewMode,
)
if (effect) setEffects(current => [effect, ...current].slice(0, 4))
}, [language, preview, previewMode, previewNonce, previewSettings?.guardEffectDurationMs])
const effect = toVisualEffect(previewEnvelope(previewMode, previewNonce), previewMode)
if (effect) queue.enqueue(effect, (previewSettings ?? settingsRef.current).queueCapacity)
}, [language, preview, previewMode, previewNonce])
useEffect(() => {
const timer = window.setInterval(() => {
const now = Date.now()
setEffects(current => current.filter(effect => effect.expiresAt > now))
}, 1_000)
return () => window.clearInterval(timer)
}, [])
return { settings, effects }
return {
settings,
effect: queue.active,
pendingCount: queue.pendingCount,
completeEffect: queue.complete,
}
}
function GiftImage({ gift }: { gift: Gift }) {
@@ -200,6 +166,7 @@ function MeteorBurst({
viewportWidth,
trailIntensity,
lowPerformance,
onComplete,
}: {
effect: VisualEffect
tier: GiftTier
@@ -208,19 +175,39 @@ function MeteorBurst({
viewportWidth: number
trailIntensity: number
lowPerformance: boolean
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count
const size = Math.max(20, tierSettings.size * scale)
const speed = Math.max(80, tierSettings.speed * scale)
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
const meteors = Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:${index}`)
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
return {
index,
startY: 7 + (seed % 78),
drift: ((seed >>> 8) % 37) - 18,
delay: index * 95 + ((seed >>> 16) % 260),
duration,
lifetime:
(reducedMotion ? Math.max(duration, 6_000) : duration) + index * 95 + ((seed >>> 16) % 260),
}
})
const lifetime = Math.max(...meteors.map(meteor => meteor.lifetime), 1_600)
return (
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:${index}`)
const startY = 7 + (seed % 78)
const drift = ((seed >>> 8) % 37) - 18
const delay = index * 95 + ((seed >>> 16) % 260)
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
<div
className={`meteor-burst tier-${tier}`}
aria-label={gift.name || translate('common.gift')}
style={{ ['--burst-duration' as string]: `${lifetime}ms` } as CSSProperties}
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.animationName === 'gift-burst-lifetime') {
onComplete()
}
}}
>
{meteors.map(({ index, startY, drift, delay, duration }) => {
return (
<div
className="gift-meteor"
@@ -254,7 +241,13 @@ function MeteorBurst({
* compact and stays out of the centre of a broadcaster's scene: a gift image,
* name and a single water-line appear briefly without adding a panel behind it.
*/
function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
function MoonlitGiftWhisper({
effect,
onComplete,
}: {
effect: VisualEffect
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
@@ -271,6 +264,13 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
} as CSSProperties
}
onAnimationEnd={event => {
if (
event.target === event.currentTarget &&
event.animationName === 'moonlit-whisper-appear'
)
onComplete()
}}
>
<i className="moonlit-whisper-ripple" aria-hidden="true" />
<span className="moonlit-whisper-image">
@@ -292,13 +292,31 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
* CSS draws moon, water ripples and falling points of light, keeping the gift
* image itself at the centre of the effect and leaving the OBS scene visible.
*/
function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: GiftTier }) {
function MoonlitGiftCeremony({
effect,
tier,
onComplete,
}: {
effect: VisualEffect
tier: GiftTier
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
return (
<section className={`moonlit-gift-ceremony tier-${tier}`} aria-live="polite">
<section
className={`moonlit-gift-ceremony tier-${tier}`}
aria-live="polite"
onAnimationEnd={event => {
if (
event.target === event.currentTarget &&
event.animationName === 'moonlit-offering-sweep'
)
onComplete()
}}
>
<div className="moonlit-ceremony-moon" aria-hidden="true" />
<div className="moonlit-ceremony-water" aria-hidden="true">
<i />
@@ -338,59 +356,6 @@ function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: Gif
)
}
function GuardCelebration({
effect,
settings,
}: {
effect: VisualEffect
settings: GiftEffectSettings
}) {
const count = settings.lowPerformanceMode
? Math.min(24, settings.guardStarCount)
: settings.guardStarCount
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const guard = effect.payload.guardName || translate('common.guard')
return (
<section
className="guard-celebration"
aria-label={translate('gift.guard_aria')}
style={
{ ['--guard-duration' as string]: `${settings.guardEffectDurationMs}ms` } as CSSProperties
}
>
<div className="guard-nebula" />
<div className="guard-stars" aria-hidden="true">
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:guard:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="guard-halo" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="guard-copy">
<span>{translate('gift.guard_salute')}</span>
<strong>{translate('gift.guard_title', { guard })}</strong>
<b>{translate('gift.guard_viewer', { viewer })}</b>
</div>
</section>
)
}
export function GiftEffectOverlay({
preview = false,
previewSettings,
@@ -428,48 +393,49 @@ export function GiftEffectOverlay({
}, [])
const scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
const guard = remote.effects.find(effect => effect.kind === 'guard')
const moonlit = theme.id === 'moonlit-water'
const effect = remote.effect
let activeVisual = null
if (effect) {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
const onComplete = () => remote.completeEffect(effect.id)
activeVisual = moonlit ? (
totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} onComplete={onComplete} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} onComplete={onComplete} key={effect.id} />
)
) : (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
onComplete={onComplete}
key={effect.id}
/>
)
}
return (
<main
ref={root}
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
data-queue-length={remote.pendingCount}
style={{
...giftThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
<div className="meteor-sky" aria-live="polite">
{remote.effects
.filter(effect => effect.kind === 'gift')
.map(effect => {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
if (moonlit) {
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} key={effect.id} />
)
}
return (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
key={effect.id}
/>
)
})}
{activeVisual}
</div>
{guard && <GuardCelebration effect={guard} settings={settings} />}
</main>
)
}
-16
View File
@@ -10,13 +10,9 @@ export type GiftEffectTheme = {
jade: string
cyan: string
gold: string
rose: string
night: string
}
motion: {
meteor: string
guardReveal: string
starPulse: string
}
}
@@ -30,13 +26,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
jade: '#72f3d8',
cyan: '#a9fff2',
gold: '#ffe7a4',
rose: '#ffd0e5',
night: '#020c18',
},
motion: {
meteor: 'gift-meteor-flight',
guardReveal: 'gift-guard-reveal',
starPulse: 'gift-star-pulse',
},
},
{
@@ -48,13 +40,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
jade: '#8daea2',
cyan: '#c6d8c6',
gold: '#e7c982',
rose: '#dbc6a0',
night: 'transparent',
},
motion: {
meteor: 'moonlit-offering-sweep',
guardReveal: 'moonlit-guard-reveal',
starPulse: 'moonlit-star-pulse',
},
},
]
@@ -72,10 +60,6 @@ export function giftThemeVariables(theme: GiftEffectTheme): CSSProperties {
['--gift-jade' as string]: theme.palette.jade,
['--gift-cyan' as string]: theme.palette.cyan,
['--gift-gold' as string]: theme.palette.gold,
['--gift-rose' as string]: theme.palette.rose,
['--gift-night' as string]: theme.palette.night,
['--gift-motion-meteor' as string]: theme.motion.meteor,
['--gift-motion-guard' as string]: theme.motion.guardReveal,
['--gift-motion-star' as string]: theme.motion.starPulse,
}
}
+422
View File
@@ -0,0 +1,422 @@
.guard-effect-overlay {
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
overflow: hidden;
color: var(--guard-cyan, #a9fff2);
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
background: transparent;
pointer-events: none;
}
.jade-guard-voyage {
position: absolute;
inset: 0;
z-index: 20;
overflow: hidden;
background: #000;
}
.jade-guard-voyage > video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
animation: jade-guard-video-fade var(--guard-duration) linear both;
}
.jade-guard-scroll {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
display: grid;
width: min(86vw, 1180px);
min-height: min(34vh, 360px);
padding: clamp(20px, 3.2vh, 42px) clamp(54px, 8vw, 130px);
place-content: center;
justify-items: center;
gap: clamp(5px, 0.9vh, 12px);
border-block: 1px solid rgba(255, 229, 153, 0.7);
color: #fff4cf;
font-family: 'HongLei XingShu', 'Liyu Shoushu', cursive;
text-align: center;
text-shadow:
0 2px 4px rgba(0, 0, 0, 0.96),
0 0 14px rgba(0, 0, 0, 0.92),
0 0 22px rgba(255, 220, 130, 0.48);
background: linear-gradient(
90deg,
transparent,
rgba(3, 15, 22, 0.34) 12%,
rgba(3, 15, 22, 0.62) 50%,
rgba(3, 15, 22, 0.34) 88%,
transparent
);
box-shadow:
0 -8px 24px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.2);
opacity: 0;
clip-path: inset(0 50% round 10px);
transform: translate(-56%, -50%);
will-change: clip-path, opacity, transform;
animation: jade-guard-scroll-reveal 4s cubic-bezier(0.22, 0.78, 0.22, 1) 4s both;
}
.jade-guard-scroll::before,
.jade-guard-scroll::after {
content: '';
position: absolute;
top: -7%;
bottom: -7%;
width: clamp(5px, 0.55vw, 10px);
border: 1px solid rgba(255, 239, 190, 0.82);
border-radius: 999px;
background: linear-gradient(90deg, #806329, #fff0b3 48%, #947132);
box-shadow: 0 0 14px rgba(255, 224, 145, 0.5);
}
.jade-guard-scroll::before {
left: clamp(13px, 2vw, 32px);
}
.jade-guard-scroll::after {
right: clamp(13px, 2vw, 32px);
}
.jade-guard-scroll strong,
.jade-guard-scroll b,
.jade-guard-scroll span {
position: relative;
z-index: 1;
overflow-wrap: anywhere;
}
.jade-guard-scroll strong {
color: #fff5d2;
font-size: clamp(42px, 7vw, 126px);
font-weight: 400;
letter-spacing: 0.12em;
line-height: 1;
}
.jade-guard-scroll b {
color: #d4fff3;
font-size: clamp(24px, 3.7vw, 66px);
font-weight: 400;
letter-spacing: 0.16em;
line-height: 1.08;
}
.jade-guard-scroll span {
color: #ffe5a6;
font-size: clamp(34px, 5.4vw, 94px);
letter-spacing: 0.18em;
line-height: 1;
}
.guard-celebration {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
overflow: hidden;
place-items: center;
opacity: 0;
color: #edfffa;
background:
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--guard-night);
animation: var(--guard-motion-reveal, guard-effect-reveal) var(--guard-duration) ease-in-out both;
}
.guard-nebula {
position: absolute;
inset: -25%;
background: conic-gradient(
from 90deg,
transparent,
rgba(93, 237, 209, 0.18),
transparent 32%,
rgba(255, 207, 230, 0.12),
transparent 68%,
rgba(255, 230, 156, 0.13),
transparent
);
filter: blur(28px);
animation: guard-nebula-turn 9s linear infinite;
}
.guard-stars {
position: absolute;
inset: 0;
}
.guard-stars i {
position: absolute;
width: var(--star-size);
height: var(--star-size);
opacity: 0.1;
background: linear-gradient(135deg, #fff8ca, var(--guard-cyan) 58%, var(--guard-rose));
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
filter: drop-shadow(0 0 6px var(--guard-cyan));
animation: var(--guard-motion-star, guard-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
}
.guard-halo {
position: absolute;
width: min(62vmin, 720px);
aspect-ratio: 1;
border: 1px solid rgba(155, 255, 235, 0.4);
border-radius: 50%;
box-shadow:
0 0 60px rgba(87, 241, 212, 0.25),
inset 0 0 70px rgba(255, 224, 166, 0.12);
animation: guard-halo-breathe 2.6s ease-in-out infinite;
}
.guard-halo i {
position: absolute;
inset: 7%;
border: 1px solid rgba(255, 227, 170, 0.38);
border-radius: 45% 55% 48% 52%;
transform: rotate(30deg);
}
.guard-halo i:nth-child(2) {
inset: 15%;
border-color: rgba(255, 195, 224, 0.32);
transform: rotate(76deg);
}
.guard-halo i:nth-child(3) {
inset: 23%;
border-color: rgba(123, 246, 224, 0.45);
transform: rotate(122deg);
}
.guard-copy {
position: relative;
z-index: 3;
display: grid;
max-width: min(82vw, 1000px);
justify-items: center;
gap: clamp(8px, 1.5vh, 20px);
text-align: center;
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
filter: brightness(var(--component-font-brightness, 1.3));
}
.guard-copy span {
color: var(--guard-gold);
font-size: clamp(13px, 1.6vw, 30px);
letter-spacing: 0.45em;
}
.guard-copy strong {
color: #f3fffc;
font-size: clamp(38px, 7vw, 132px);
font-weight: 500;
letter-spacing: 0.1em;
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
}
.guard-copy b {
color: var(--guard-rose);
font-size: clamp(16px, 2.3vw, 44px);
font-weight: 500;
letter-spacing: 0.12em;
}
.guard-low-motion .guard-nebula,
.guard-low-motion .guard-halo {
animation: none;
}
.guard-theme-moonlit-water {
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.guard-theme-moonlit-water .guard-celebration {
color: #eadfc3;
background: transparent;
font-family: inherit;
}
.guard-theme-moonlit-water .guard-celebration::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: min(62vmin, 780px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(246, 237, 207, 0.82) 0 42%,
rgba(226, 224, 193, 0.2) 63%,
transparent 71%
);
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
transform: translate(-50%, -50%);
}
.guard-theme-moonlit-water .guard-nebula {
background:
repeating-radial-gradient(
ellipse at 50% 80%,
transparent 0 7%,
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
transparent 7.7% 12%
),
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
filter: blur(1px);
animation: moonlit-guard-water 7s ease-in-out infinite;
}
.guard-theme-moonlit-water .guard-stars i {
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
}
.guard-theme-moonlit-water .guard-halo {
width: min(77vmin, 930px);
border-color: rgba(231, 201, 130, 0.42);
box-shadow:
0 0 44px rgba(231, 201, 130, 0.18),
inset 0 0 65px rgba(182, 207, 180, 0.1);
}
.guard-theme-moonlit-water .guard-halo i {
border-color: rgba(203, 219, 184, 0.36);
}
.guard-theme-moonlit-water .guard-copy {
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
}
.guard-theme-moonlit-water .guard-copy span,
.guard-theme-moonlit-water .guard-copy b {
color: #e7c982;
}
.guard-theme-moonlit-water .guard-copy strong {
color: #eadfc3;
filter: none;
}
@keyframes guard-effect-reveal {
0%,
100% {
opacity: 0;
}
8%,
86% {
opacity: 1;
}
}
@keyframes jade-guard-video-fade {
0%,
100% {
opacity: 0;
}
6.25%,
93.75% {
opacity: 1;
}
}
@keyframes jade-guard-scroll-reveal {
0% {
opacity: 0;
clip-path: inset(0 50% round 10px);
transform: translate(-56%, -50%);
}
12.5%,
87.5% {
opacity: 1;
clip-path: inset(0 round 10px);
transform: translate(-50%, -50%);
}
100% {
opacity: 0;
clip-path: inset(0 round 10px);
transform: translate(-46%, -50%);
}
}
@keyframes jade-guard-copy-fade {
0%,
100% {
opacity: 0;
}
12.5%,
87.5% {
opacity: 1;
}
}
@keyframes guard-star-pulse {
0%,
100% {
opacity: 0.08;
transform: rotate(0) scale(0.45);
}
48% {
opacity: 1;
transform: rotate(50deg) scale(1.32);
}
}
@keyframes guard-nebula-turn {
to {
transform: rotate(360deg);
}
}
@keyframes guard-halo-breathe {
50% {
transform: scale(1.08) rotate(3deg);
box-shadow:
0 0 110px rgba(87, 241, 212, 0.38),
inset 0 0 90px rgba(255, 224, 166, 0.2);
}
}
@keyframes moonlit-guard-water {
50% {
opacity: 0.68;
transform: scale(1.04) translateY(-1.4%);
}
}
@keyframes moonlit-guard-reveal {
0%,
100% {
opacity: 0;
}
8%,
86% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.guard-nebula,
.guard-stars i,
.guard-halo {
animation: none;
}
.jade-guard-scroll {
clip-path: none;
transform: translate(-50%, -50%);
animation-name: jade-guard-copy-fade;
}
}
+286
View File
@@ -0,0 +1,286 @@
/** Full-viewport renderer for the independently subscribed membership component. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGuardEffectSettings } from './api'
import { useEffectQueue } from './effectQueue'
import { getGuardEffectTheme, guardThemeVariables } from './guardThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGuardEffectSettings } from './types'
import type { GuardEffectSettings } from './types'
import { typographyVariables } from './typography'
type Viewer = { uid?: string; name?: string }
type GuardPayload = {
viewer?: Viewer
guardName?: string
quantity?: number
price?: number
settings?: Partial<GuardEffectSettings>
}
type GuardEnvelope = { id: string; type: string; payload?: GuardPayload }
type GuardEffect = {
id: string
payload: GuardPayload
}
type GuardLevel = 'captain' | 'admiral' | 'governor'
export type GuardEffectPreviewMode = GuardLevel
const JADE_VIDEO_DURATION_MS = 8_000
function guardLevel(guardName: string | undefined): GuardLevel {
const normalized = guardName?.trim().toLocaleLowerCase() ?? ''
if (normalized.includes('总督') || normalized.includes('governor')) return 'governor'
if (normalized.includes('提督') || normalized.includes('admiral')) return 'admiral'
return 'captain'
}
function guardVideoUrl(level: GuardLevel): string {
return `/assets/${level === 'governor' ? 'general' : level}.webm`
}
function previewEnvelope(level: GuardLevel, nonce: number): GuardEnvelope {
return {
id: `preview-${level}-${nonce}`,
type: 'live.guard.buy',
payload: {
viewer: { uid: '10001', name: translate('gift.preview.viewer') },
guardName: translate(`gift_menu.guard.${level}`),
quantity: 1,
price: 198_000,
},
}
}
function toGuardEffect(envelope: GuardEnvelope): GuardEffect | undefined {
if (envelope.type !== 'live.guard.buy') return undefined
return {
id: envelope.id,
payload: envelope.payload ?? {},
}
}
function useGuardEffect(
preview: boolean,
stream: ComponentStream | undefined,
previewSettings: GuardEffectSettings | undefined,
previewMode: GuardEffectPreviewMode,
previewNonce: number,
language: string,
) {
const [settings, setSettings] = useState(defaultGuardEffectSettings)
const queue = useEffectQueue<GuardEffect>()
const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
useEffect(() => {
settingsRef.current = settings
}, [settings])
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
for (const message of pending) {
lastSequenceRef.current = message.sequence
const envelope = message.envelope as GuardEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
const next = normalizeGuardEffectSettings(envelope.payload?.settings)
settingsRef.current = next
setSettings(next)
continue
}
const next = toGuardEffect(envelope)
if (next) queue.enqueue(next, settingsRef.current.queueCapacity)
}
}, [preview, stream, stream?.messages])
useEffect(() => {
if (!preview) return
const currentSettings = previewSettings ?? settingsRef.current
const effect = toGuardEffect(previewEnvelope(previewMode, previewNonce))
if (effect) queue.enqueue(effect, currentSettings.queueCapacity)
}, [
language,
preview,
previewMode,
previewNonce,
previewSettings?.effectDurationMs,
previewSettings?.themeId,
])
return {
settings,
effect: queue.active,
pendingCount: queue.pendingCount,
completeEffect: queue.complete,
}
}
function hash(value: string): number {
let result = 2166136261
for (let index = 0; index < value.length; index += 1) {
result ^= value.charCodeAt(index)
result = Math.imul(result, 16777619)
}
return result >>> 0
}
function JadeGuardVoyage({
effect,
settings,
muted,
onComplete,
}: {
effect: GuardEffect
settings: GuardEffectSettings
muted: boolean
onComplete: () => void
}) {
const level = guardLevel(effect.payload.guardName)
const guard = translate(`gift_menu.guard.${level}`)
const viewer =
effect.payload.viewer?.uid || effect.payload.viewer?.name || translate('common.viewer')
const title = settings.titleTemplate.replaceAll('{guard}', guard)
return (
<section
className={`jade-guard-voyage guard-level-${level}`}
aria-label={translate('guard.effect_aria')}
style={{ ['--guard-duration' as string]: `${JADE_VIDEO_DURATION_MS}ms` } as CSSProperties}
>
<video
autoPlay
playsInline
preload="auto"
muted={muted}
src={guardVideoUrl(level)}
key={`${effect.id}:${level}`}
aria-hidden="true"
onAnimationEnd={event => {
if (event.animationName === 'jade-guard-video-fade') onComplete()
}}
/>
<div className="jade-guard-scroll" aria-live="polite">
<strong>{title}</strong>
<b>{viewer}</b>
<span>{settings.closingText}</span>
</div>
</section>
)
}
function MoonlitGuardCelebration({
effect,
settings,
onComplete,
}: {
effect: GuardEffect
settings: GuardEffectSettings
onComplete: () => void
}) {
const count = settings.lowPerformanceMode ? Math.min(24, settings.starCount) : settings.starCount
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const guard = effect.payload.guardName || translate('common.guard')
return (
<section
className="guard-celebration"
aria-label={translate('guard.effect_aria')}
style={{ ['--guard-duration' as string]: `${settings.effectDurationMs}ms` } as CSSProperties}
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.animationName === 'moonlit-guard-reveal')
onComplete()
}}
>
<div className="guard-nebula" />
<div className="guard-stars" aria-hidden="true">
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:guard:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="guard-halo" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="guard-copy">
<span>{translate('guard.salute')}</span>
<strong>{translate('guard.title', { guard })}</strong>
<b>{translate('guard.viewer', { viewer })}</b>
</div>
</section>
)
}
export function GuardEffectOverlay({
preview = false,
previewSettings,
previewMode = 'captain',
previewNonce = 0,
stream,
}: {
preview?: boolean
previewSettings?: GuardEffectSettings
previewMode?: GuardEffectPreviewMode
previewNonce?: number
stream?: ComponentStream
}) {
const { language } = useI18n()
const remote = useGuardEffect(
preview,
stream,
previewSettings,
previewMode,
previewNonce,
language,
)
const settings = previewSettings ?? remote.settings
const theme = getGuardEffectTheme(settings.themeId)
const effect = remote.effect
const onComplete = effect ? () => remote.completeEffect(effect.id) : undefined
return (
<main
className={`guard-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'guard-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
data-queue-length={remote.pendingCount}
style={{
...guardThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
{effect &&
onComplete &&
(theme.id === 'moonlit-water' ? (
<MoonlitGuardCelebration
key={effect.id}
effect={effect}
settings={settings}
onComplete={onComplete}
/>
) : (
<JadeGuardVoyage
key={effect.id}
effect={effect}
settings={settings}
muted={preview}
onComplete={onComplete}
/>
))}
</main>
)
}
+73
View File
@@ -0,0 +1,73 @@
import type { CSSProperties } from 'react'
import type { GuardEffectThemeId } from './types'
export type GuardEffectTheme = {
id: GuardEffectThemeId
nameKey: string
descriptionKey: string
className: string
palette: {
cyan: string
gold: string
rose: string
night: string
}
motion: {
reveal: string
starPulse: string
}
}
export const guardEffectThemes: readonly GuardEffectTheme[] = [
{
id: 'jade-starfall',
nameKey: 'guard.theme.jade_starfall.name',
descriptionKey: 'guard.theme.jade_starfall.description',
className: 'guard-theme-jade-starfall',
palette: {
cyan: '#a9fff2',
gold: '#ffe7a4',
rose: '#ffd0e5',
night: '#020c18',
},
motion: {
reveal: 'guard-effect-reveal',
starPulse: 'guard-star-pulse',
},
},
{
id: 'moonlit-water',
nameKey: 'guard.theme.moonlit_water.name',
descriptionKey: 'guard.theme.moonlit_water.description',
className: 'guard-theme-moonlit-water',
palette: {
cyan: '#c6d8c6',
gold: '#e7c982',
rose: '#dbc6a0',
night: 'transparent',
},
motion: {
reveal: 'moonlit-guard-reveal',
starPulse: 'guard-star-pulse',
},
},
]
export function getGuardEffectTheme(id: unknown): GuardEffectTheme {
return guardEffectThemes.find(theme => theme.id === id) ?? guardEffectThemes[0]
}
export function normalizeGuardEffectThemeId(id: unknown): GuardEffectThemeId {
return getGuardEffectTheme(id).id
}
export function guardThemeVariables(theme: GuardEffectTheme): CSSProperties {
return {
['--guard-cyan' as string]: theme.palette.cyan,
['--guard-gold' as string]: theme.palette.gold,
['--guard-rose' as string]: theme.palette.rose,
['--guard-night' as string]: theme.palette.night,
['--guard-motion-reveal' as string]: theme.motion.reveal,
['--guard-motion-star' as string]: theme.motion.starPulse,
}
}
+3
View File
@@ -18,6 +18,7 @@ import {
SongRequestsPage,
} from './control'
import { GiftEffectOverlay } from './giftEffect'
import { GuardEffectOverlay } from './guardEffect'
import { GiftMenuOverlay } from './giftMenu'
import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
@@ -32,6 +33,7 @@ import './fonts.css'
import './style.css'
import './control.css'
import './giftEffect.css'
import './guardEffect.css'
import './giftMenu.css'
import './song.css'
import './themeEdges.css'
@@ -48,6 +50,7 @@ function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken
return <main className="obs-status">{t('main.obs_invalid_token')}</main>
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
if (stream.componentKind === 'gift_effect') return <GiftEffectOverlay stream={stream} />
if (stream.componentKind === 'guard_effect') return <GuardEffectOverlay stream={stream} />
if (stream.componentKind === 'gift_menu') return <GiftMenuOverlay stream={stream} />
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
return <Overlay stream={stream} />
+6 -2
View File
@@ -390,6 +390,10 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
}, [items.length, language, preview, setItems])
useEffect(() => {
if (!settings.expandNewDanmaku) {
setExpandedKey(undefined)
return
}
const newest = items[items.length - 1]
if (!newest) {
setExpandedKey(undefined)
@@ -402,7 +406,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
settings.collapseAfterSeconds * 1000 * densityFactor,
)
return () => window.clearTimeout(timer)
}, [items, settings.collapseAfterSeconds, shape])
}, [items, settings.collapseAfterSeconds, settings.expandNewDanmaku, shape])
return (
<main
@@ -435,7 +439,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
<Card
item={item}
settings={settings}
expanded={item.key === expandedKey}
expanded={settings.expandNewDanmaku && item.key === expandedKey}
theme={theme}
key={item.key}
/>
+3 -49
View File
@@ -73,8 +73,7 @@ body,
display: none;
}
.song-current-copy,
.song-score {
.song-current-copy {
position: relative;
z-index: 1;
display: grid;
@@ -83,15 +82,13 @@ body,
}
.song-current-copy,
.song-score,
.song-queue > header,
.song-row,
.song-queue-empty {
filter: brightness(var(--component-font-brightness, 1.3));
}
.song-current-copy small,
.song-score small {
.song-current-copy small {
color: var(--component-song-requester-color, var(--theme-compact-user));
font-size: 0.68em;
}
@@ -104,16 +101,6 @@ body,
text-shadow: 0 0 20px rgba(133, 255, 232, 0.2);
}
.song-score {
justify-items: end;
white-space: nowrap;
}
.song-score b {
font-size: 0.92em;
color: var(--theme-price);
}
.song-queue {
display: grid;
min-height: 0;
@@ -359,13 +346,6 @@ body,
grid-template-columns: auto minmax(0, 1fr);
}
.song-overlay.song-narrow .song-score {
grid-column: 2;
grid-template-columns: auto auto;
justify-items: start;
gap: 0.5em;
}
.song-overlay.song-short .song-current {
min-height: 42px;
padding-block: 4px;
@@ -458,14 +438,12 @@ body,
line-height: 1;
}
.theme-moonlit-water .song-current-copy,
.theme-moonlit-water .song-score {
.theme-moonlit-water .song-current-copy {
gap: 0.18em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
}
.theme-moonlit-water .song-current-copy small,
.theme-moonlit-water .song-score small,
.theme-moonlit-water .song-requester,
.theme-moonlit-water .song-queue > header {
color: var(--component-song-requester-color, #a9b79b);
@@ -480,27 +458,10 @@ body,
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
}
.theme-moonlit-water .song-score b,
.theme-moonlit-water .song-index {
color: #c9b06f;
}
.theme-moonlit-water .song-score {
min-width: clamp(72px, 6.8em, 116px);
justify-items: end;
align-self: center;
}
.theme-moonlit-water .song-score b {
font-size: 0.68em;
font-weight: 500;
letter-spacing: 0.04em;
}
.theme-moonlit-water .song-score small {
display: none;
}
.theme-moonlit-water .song-waveform {
display: flex;
height: 0.8em;
@@ -634,13 +595,6 @@ body,
grid-template-columns: auto minmax(0, 1fr) auto;
}
.song-overlay.theme-moonlit-water.song-narrow .song-score {
grid-column: auto;
grid-template-columns: 1fr;
min-width: 4.8em;
justify-items: end;
}
.song-overlay.theme-moonlit-water.song-short {
gap: 6px;
padding-block: 6px;
+4 -19
View File
@@ -39,8 +39,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
queuePosition: 0,
requestedAt: now,
startedAt: now,
averageScore: 4.8,
ratingCount: 26,
},
queued: titles.map((title, index) => ({
id: `preview-${index}`,
@@ -49,7 +47,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
status: 'queued',
queuePosition: index + 1,
requestedAt: now,
ratingCount: 0,
})),
}
}
@@ -199,14 +196,6 @@ function useSongQueue(preview: boolean, language: string, stream?: ComponentStre
return { settings, queue }
}
function score(item?: SongRequestItem) {
if (!item?.ratingCount || item.averageScore == null) return translate('song.no_rating')
return translate('song.overlay.score', {
score: item.averageScore.toFixed(1),
count: item.ratingCount,
})
}
/** Theme-owned stars and florets used inside the compact current-song card. */
function SongParticles({ theme, count = 8 }: { theme: OverlayThemeDefinition; count?: number }) {
return (
@@ -358,14 +347,10 @@ export function SongRequestOverlay({
<strong>{translate('song.overlay.request_help')}</strong>
</div>
)}
<div className="song-score">
<b>{score(queue.current)}</b>
<small>{translate('song.overlay.rate_help')}</small>
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</section>
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
+37 -11
View File
@@ -24,6 +24,7 @@ export type OverlaySettings = {
showLike: boolean
showShare: boolean
maxVisible: number
expandNewDanmaku: boolean
collapseAfterSeconds: number
unfoldDurationMs: number
motionIntensity: number
@@ -50,6 +51,7 @@ export const defaultOverlaySettings: OverlaySettings = {
showLike: false,
showShare: false,
maxVisible: 5,
expandNewDanmaku: true,
collapseAfterSeconds: 12,
unfoldDurationMs: 1000,
motionIntensity: 70,
@@ -100,7 +102,7 @@ export type MeteorTierSettings = {
speed: number
}
/** Full-viewport visual settings for gift meteors and guard celebrations. */
/** Full-viewport visual settings for gifts. */
export type GiftEffectSettings = {
themeId: GiftEffectThemeId
fontFamily: FontFamilyId
@@ -111,9 +113,7 @@ export type GiftEffectSettings = {
high: MeteorTierSettings
featured: MeteorTierSettings
trailIntensity: number
guardStarCount: number
guardEffectDurationMs: number
maxConcurrentEffects: number
queueCapacity: number
lowPerformanceMode: boolean
}
@@ -127,9 +127,34 @@ export const defaultGiftEffectSettings: GiftEffectSettings = {
high: { count: 6, size: 126, speed: 720 },
featured: { count: 10, size: 168, speed: 880 },
trailIntensity: 78,
guardStarCount: 48,
guardEffectDurationMs: 5_200,
maxConcurrentEffects: 8,
queueCapacity: 256,
lowPerformanceMode: false,
}
export type GuardEffectThemeId = 'jade-starfall' | 'moonlit-water'
/** Full-viewport visual settings for membership-purchase celebrations. */
export type GuardEffectSettings = {
themeId: GuardEffectThemeId
fontFamily: FontFamilyId
fontBrightness: number
starCount: number
effectDurationMs: number
titleTemplate: string
closingText: string
queueCapacity: number
lowPerformanceMode: boolean
}
export const defaultGuardEffectSettings: GuardEffectSettings = {
themeId: 'jade-starfall',
fontFamily: 'fang-song',
fontBrightness: 130,
starCount: 48,
effectDurationMs: 5_200,
titleTemplate: '{guard}启航',
closingText: '相伴前行',
queueCapacity: 256,
lowPerformanceMode: false,
}
@@ -195,7 +220,11 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
}
export type ComponentSettings =
OverlaySettings | SongRequestSettings | GiftEffectSettings | GiftMenuSettings
| OverlaySettings
| SongRequestSettings
| GiftEffectSettings
| GuardEffectSettings
| GiftMenuSettings
export type SongRequester = { uid: string; name: string }
@@ -208,8 +237,6 @@ export type SongRequestItem = {
requestedAt: string
startedAt?: string | null
finishedAt?: string | null
averageScore?: number | null
ratingCount: number
}
export type SongQueueSummary = {
@@ -217,7 +244,6 @@ export type SongQueueSummary = {
queuedCount: number
completedCount: number
cancelledCount: number
ratingCount: number
}
export type SongRequestPage = {