add full-screen gift effects

This commit is contained in:
2026-07-19 00:39:56 -07:00
parent f79852d8e6
commit c4eca7b8bf
23 changed files with 1666 additions and 33 deletions
+8 -3
View File
@@ -1,7 +1,8 @@
# 洛星瓷直播组件服务
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request`
点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬与
`gift_effect`
全屏礼物特效,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
crate。它负责 Cookie/WBI
@@ -19,6 +20,7 @@ adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。cr
- [组件开发指南](docs/components/README.md)
- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md)
- [`song_request` 点歌姬](docs/components/song-request.md)
- [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md)
- [WebSocket 实时协议](docs/protocol.md)
- [租户、Secret 与部署安全](docs/security.md)
- [完整配置注释](config.toml.example)
@@ -208,9 +210,12 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通
`/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
每个账户会自动拥有一个不可删除的点歌姬。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5`
每个账户会自动拥有不可删除的点歌姬和全屏礼物特效组件。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5`
为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度;舰长、提督和总督事件会临时覆盖一层不透明星河庆祝画面。所有档位参数、拖尾、星数、持续时间和低性能模式均可在控制台调整。该组件只订阅一次性
`live.gift` 与 `live.guard.buy`,不会把连击更新重复播放为新礼物。
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer
token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
+3
View File
@@ -26,6 +26,8 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 |
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
| `src/giftEffect.tsx` | 礼物流星、大航海全屏庆祝与视口自适应渲染 |
| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/i18n.tsx` | TOML 语言资源、浏览器回退和运行时切换 |
| `src/types.ts` | sanitized API view model 与 overlay settings |
@@ -70,3 +72,4 @@ YAML 和项目文档。
- [实时协议](../../docs/protocol.md)
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
- [点歌姬组件](../../docs/components/song-request.md)
- [全屏礼物特效](../../docs/components/gift-effect.md)
+23 -1
View File
@@ -11,6 +11,7 @@ import type {
AuthUser,
ComponentSummary,
CookieCloudSource,
GiftEffectSettings,
Invitation,
OverlaySettings,
Session,
@@ -20,7 +21,12 @@ import type {
TotpEnrollment,
} from './types'
import { normalizeThemeId } from './themes'
import { defaultOverlaySettings, defaultSongRequestSettings } from './types'
import { normalizeGiftEffectThemeId } from './giftThemes'
import {
defaultGiftEffectSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
} from './types'
import { currentLanguage, hasTranslation, translate } from './i18n'
export class ApiError extends Error {
@@ -214,6 +220,22 @@ export function normalizeSongRequestSettings(value: unknown): SongRequestSetting
}
}
export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings {
const root = object(value)
const settings = object(root.settings ?? value)
const normal = object(settings.normal)
const high = object(settings.high)
const featured = object(settings.featured)
return {
...defaultGiftEffectSettings,
...(settings as Partial<GiftEffectSettings>),
themeId: normalizeGiftEffectThemeId(settings.themeId),
normal: { ...defaultGiftEffectSettings.normal, ...normal },
high: { ...defaultGiftEffectSettings.high, ...high },
featured: { ...defaultGiftEffectSettings.featured, ...featured },
}
}
export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined {
const item = object(value)
const requester = object(item.requester)
+85 -2
View File
@@ -27,6 +27,86 @@
gap: 7px;
}
.gift-thresholds {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gift-tier-heading {
display: grid;
gap: 5px;
margin: 22px 0 10px;
}
.gift-tier-heading small {
color: var(--app-muted);
}
.gift-tier-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.gift-tier-grid fieldset {
min-width: 0;
margin: 0;
padding: 16px;
border: 1px solid rgba(111, 228, 211, 0.18);
border-radius: 15px;
background: rgba(4, 29, 39, 0.42);
}
.gift-tier-grid legend {
padding: 0 7px;
color: #b8f9ec;
}
.gift-tier-grid label {
display: grid;
gap: 5px;
margin-block: 10px;
}
.gift-tier-grid label span {
display: flex;
justify-content: space-between;
gap: 10px;
}
.gift-global-settings {
margin-top: 22px;
}
.compact-toggle-grid {
margin-top: 18px;
}
.gift-preview-viewport {
width: min(100%, 960px);
aspect-ratio: 16 / 9;
overflow: hidden;
margin-top: 16px;
border: 1px solid rgba(103, 231, 211, 0.28);
background-color: #091820;
background-image:
linear-gradient(45deg, rgba(113, 191, 181, 0.08) 25%, transparent 25%),
linear-gradient(-45deg, rgba(113, 191, 181, 0.08) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(113, 191, 181, 0.08) 75%),
linear-gradient(-45deg, transparent 75%, rgba(113, 191, 181, 0.08) 75%);
background-position:
0 0,
0 12px,
12px -12px,
-12px 0;
background-size: 24px 24px;
box-shadow: inset 0 0 60px rgba(0, 8, 15, 0.46);
}
.gift-preview-viewport .gift-effect-overlay {
width: 100%;
height: 100%;
}
.song-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -109,7 +189,8 @@
@media (max-width: 760px) {
.limit-grid,
.song-stat-grid {
.song-stat-grid,
.gift-tier-grid {
grid-template-columns: 1fr 1fr;
}
@@ -129,7 +210,9 @@
@media (max-width: 480px) {
.limit-grid,
.song-stat-grid {
.song-stat-grid,
.gift-tier-grid,
.gift-thresholds {
grid-template-columns: 1fr;
}
}
+315 -6
View File
@@ -15,6 +15,7 @@ import {
errorMessage,
json,
normalizeComponents,
normalizeGiftEffectSettings,
normalizeInvitations,
normalizeSettings,
normalizeSongRequestPage,
@@ -22,16 +23,24 @@ import {
normalizeSource,
} from './api'
import { Overlay } from './overlay'
import { GiftEffectOverlay } from './giftEffect'
import type { GiftEffectPreviewMode } from './giftEffect'
import { getGiftEffectTheme, giftEffectThemes } from './giftThemes'
import { PwaControls, usePwaUpdateBlocker } from './pwa'
import { SongRequestOverlay } from './songOverlay'
import { getOverlayTheme, overlayThemes } from './themes'
import { currentLanguage, LanguageSelect, translate, useI18n } from './i18n'
import { defaultOverlaySettings, defaultSongRequestSettings } from './types'
import {
defaultGiftEffectSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
} from './types'
import type {
AuthUser,
ComponentSettings,
ComponentSummary,
CookieCloudSource,
GiftEffectSettings,
Invitation,
OverlaySettings,
SongRequestItem,
@@ -54,6 +63,10 @@ function isSongRequestKind(kind: string): boolean {
return kind === 'song_request'
}
function isGiftEffectKind(kind: string): boolean {
return kind === 'gift_effect'
}
type Flash = { kind: 'success' | 'error'; text: string } | undefined
function Panel({
@@ -519,6 +532,223 @@ function SongRequestPreview({ settings }: { settings: SongRequestSettings }) {
)
}
const giftTiers = ['normal', 'high', 'featured'] as const
function GiftEffectSettingsEditor({
settings,
onChange,
onSave,
saving,
}: {
settings: GiftEffectSettings
onChange: (settings: GiftEffectSettings) => void
onSave: () => Promise<void>
saving: boolean
}) {
const edit = <K extends keyof GiftEffectSettings>(key: K, value: GiftEffectSettings[K]) =>
onChange({ ...settings, [key]: value })
const editTier = (
tier: (typeof giftTiers)[number],
key: 'count' | 'size' | 'speed',
value: number,
) => onChange({ ...settings, [tier]: { ...settings[tier], [key]: value } })
const theme = getGiftEffectTheme(settings.themeId)
return (
<div className="settings-editor gift-effect-settings">
<div className="field-grid theme-selector">
<label>
{translate('settings.theme')}
<select
value={settings.themeId}
onChange={event => edit('themeId', event.target.value as GiftEffectSettings['themeId'])}
>
{giftEffectThemes.map(candidate => (
<option value={candidate.id} key={candidate.id}>
{translate(candidate.nameKey)}
</option>
))}
</select>
<small>{translate(theme.descriptionKey)}</small>
</label>
</div>
<fieldset className="limit-grid gift-thresholds">
<legend>{translate('gift.settings.thresholds')}</legend>
<label>
{translate('gift.settings.high_threshold')}
<input
type="number"
min="0"
value={settings.highValueThreshold}
onChange={event => edit('highValueThreshold', +event.target.value)}
/>
</label>
<label>
{translate('gift.settings.featured_threshold')}
<input
type="number"
min="0"
value={settings.featuredValueThreshold}
onChange={event => edit('featuredValueThreshold', +event.target.value)}
/>
</label>
</fieldset>
<div className="gift-tier-heading">
<b>{translate('gift.settings.tiers')}</b>
<small>{translate('gift.settings.tiers_description')}</small>
</div>
<div className="gift-tier-grid">
{giftTiers.map(tier => (
<fieldset key={tier}>
<legend>{translate(`gift.settings.tier.${tier}`)}</legend>
<label>
<span>
{translate('gift.settings.count')} <output>{settings[tier].count}</output>
</span>
<input
type="range"
min="1"
max="24"
value={settings[tier].count}
onChange={event => editTier(tier, 'count', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift.settings.size')} <output>{settings[tier].size}px</output>
</span>
<input
type="range"
min="24"
max="480"
step="4"
value={settings[tier].size}
onChange={event => editTier(tier, 'size', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift.settings.speed')} <output>{settings[tier].speed}px/s</output>
</span>
<input
type="range"
min="100"
max="2500"
step="20"
value={settings[tier].speed}
onChange={event => editTier(tier, 'speed', +event.target.value)}
/>
</label>
</fieldset>
))}
</div>
<div className="slider-grid gift-global-settings">
<label>
<span>
{translate('gift.settings.trail')} <output>{settings.trailIntensity}%</output>
</span>
<input
type="range"
min="0"
max="100"
value={settings.trailIntensity}
onChange={event => edit('trailIntensity', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift.settings.guard_stars')} <output>{settings.guardStarCount}</output>
</span>
<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"
min="1"
max="12"
value={settings.maxConcurrentEffects}
onChange={event => edit('maxConcurrentEffects', +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="form-actions align-end">
<button type="button" disabled={saving} onClick={() => void onSave()}>
{saving ? translate('settings.saving') : translate('settings.save_sync')}
</button>
</div>
</div>
)
}
function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) {
const [mode, setMode] = useState<GiftEffectPreviewMode>('high')
const [nonce, setNonce] = useState(0)
const trigger = (next: GiftEffectPreviewMode) => {
setMode(next)
setNonce(current => current + 1)
}
return (
<Panel
title={translate('gift.preview.title')}
description={translate('gift.preview.description')}
className="preview-panel"
>
<div className="preset-buttons gift-preview-buttons">
{(['normal', 'high', 'featured', 'guard'] as const).map(candidate => (
<button
type="button"
className={candidate === mode ? 'active' : 'secondary'}
onClick={() => trigger(candidate)}
key={candidate}
>
{translate(`gift.preview.${candidate}_button`)}
</button>
))}
</div>
<div className="gift-preview-viewport">
<GiftEffectOverlay
preview
previewSettings={settings}
previewMode={mode}
previewNonce={nonce}
/>
</div>
</Panel>
)
}
function SourceEditor({
source,
onSaved,
@@ -791,14 +1021,24 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) {
)
}
function TestEvents({ componentId }: { componentId: string }) {
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift'>('danmaku')
function TestEvents({
componentId,
giftOnly = false,
}: {
componentId: string
giftOnly?: boolean
}) {
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(
giftOnly ? 'gift' : 'danmaku',
)
const [uid, setUid] = useState('test-viewer')
const [name, setName] = useState(() => translate('test.default_viewer'))
const [text, setText] = useState(() => translate('test.default_text'))
const [giftName, setGiftName] = useState(() => translate('test.default_gift'))
const [quantity, setQuantity] = useState(1)
const [battery, setBattery] = useState(100)
const [guardName, setGuardName] = useState(() => translate('test.default_guard'))
const [guardPrice, setGuardPrice] = useState(198000)
const [flash, setFlash] = useState<Flash>()
const [busy, setBusy] = useState(false)
@@ -815,6 +1055,7 @@ function TestEvents({ componentId }: { componentId: string }) {
name,
...(kind === 'danmaku' ? { text } : {}),
...(kind === 'gift' ? { giftName, quantity, battery } : {}),
...(kind === 'guard' ? { guardName, quantity, price: guardPrice } : {}),
}),
)
setFlash({ kind: 'success', text: translate('test.sent') })
@@ -831,9 +1072,10 @@ function TestEvents({ componentId }: { componentId: string }) {
<label>
{translate('test.event_type')}
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
<option value="danmaku">{translate('settings.event.danmaku')}</option>
<option value="enter">{translate('test.enter')}</option>
{!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>
</select>
</label>
<label>
@@ -882,6 +1124,38 @@ function TestEvents({ componentId }: { componentId: string }) {
</label>
</>
)}
{kind === 'guard' && (
<>
<label>
{translate('test.guard_name')}
<input
required
value={guardName}
onChange={event => setGuardName(event.target.value)}
/>
</label>
<label>
{translate('test.quantity')}
<input
required
type="number"
min="1"
value={quantity}
onChange={event => setQuantity(+event.target.value)}
/>
</label>
<label>
{translate('test.price')}
<input
required
type="number"
min="0"
value={guardPrice}
onChange={event => setGuardPrice(+event.target.value)}
/>
</label>
</>
)}
<FlashMessage flash={flash} />
<div className="form-actions align-end span-all">
<button disabled={busy}>
@@ -927,6 +1201,8 @@ function ComponentList({
? translate('components.danmaku_mark')
: isSongRequestKind(component.kind)
? translate('components.song_mark')
: isGiftEffectKind(component.kind)
? translate('components.gift_mark')
: translate('components.generic_mark')}
</span>
<span>
@@ -935,6 +1211,8 @@ function ComponentList({
? translate('components.danmaku_type')
: isSongRequestKind(component.kind)
? translate('components.song_type')
: isGiftEffectKind(component.kind)
? translate('components.gift_type')
: component.name}
</b>
<small>
@@ -942,6 +1220,8 @@ function ComponentList({
? translate('components.danmaku_type')
: isSongRequestKind(component.kind)
? translate('components.song_type')
: isGiftEffectKind(component.kind)
? translate('components.gift_type')
: component.kind}
</small>
</span>
@@ -1000,6 +1280,8 @@ export function ComponentsPage({
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
const next = isSongRequestKind(component.kind)
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
: isGiftEffectKind(component.kind)
? { ...defaultGiftEffectSettings, ...normalizeGiftEffectSettings(payload) }
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
@@ -1071,6 +1353,8 @@ export function ComponentsPage({
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
const next = isSongRequestKind(selected.kind)
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
: isGiftEffectKind(selected.kind)
? { ...defaultGiftEffectSettings, ...normalizeGiftEffectSettings(payload) }
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
@@ -1105,6 +1389,8 @@ export function ComponentsPage({
? translate('components.danmaku_type')
: isSongRequestKind(selected.kind)
? translate('components.song_type')
: isGiftEffectKind(selected.kind)
? translate('components.gift_type')
: selected.kind}
</p>
<h1>
@@ -1112,6 +1398,8 @@ export function ComponentsPage({
? translate('components.danmaku_type')
: isSongRequestKind(selected.kind)
? translate('components.song_type')
: isGiftEffectKind(selected.kind)
? translate('components.gift_type')
: selected.name}
</h1>
</div>
@@ -1172,13 +1460,34 @@ export function ComponentsPage({
</Panel>
<SongRequestPreview settings={settings as SongRequestSettings} />
</>
) : isGiftEffectKind(selected.kind) && settings ? (
<>
<Panel
title={translate('components.gift_settings')}
description={translate('components.gift_description')}
>
<GiftEffectSettingsEditor
settings={settings as GiftEffectSettings}
onChange={next => setSettings(next)}
onSave={saveSettings}
saving={saving}
/>
</Panel>
<GiftEffectPreview settings={settings as GiftEffectSettings} />
</>
) : (
<Panel title={translate('components.generic_settings')}>
<div className="empty-state">{translate('components.no_editor')}</div>
</Panel>
)}
<ObsAccessPanel component={selected} key={selected.id} />
{isDanmakuKind(selected.kind) && <TestEvents componentId={selected.id} />}
{(isDanmakuKind(selected.kind) || isGiftEffectKind(selected.kind)) && (
<TestEvents
componentId={selected.id}
giftOnly={isGiftEffectKind(selected.kind)}
key={`test-${selected.id}`}
/>
)}
</>
)}
{!loading && !selected && (
+355
View File
@@ -0,0 +1,355 @@
.gift-effect-overlay {
position: relative;
isolation: isolate;
/* Percentages fill both a real OBS viewport and the nested control preview. */
width: 100%;
height: 100%;
overflow: hidden;
color: var(--gift-cyan, #a9fff2);
background: transparent;
pointer-events: none;
}
.meteor-sky,
.meteor-burst {
position: absolute;
inset: 0;
overflow: hidden;
}
.gift-meteor {
position: absolute;
top: var(--meteor-y);
left: 0;
width: var(--meteor-size);
height: var(--meteor-size);
opacity: 0;
will-change: transform, opacity;
animation: var(--gift-motion-meteor, gift-meteor-flight) var(--meteor-duration) linear
var(--meteor-delay) both;
}
.meteor-core {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
overflow: hidden;
place-items: center;
border: max(2px, calc(var(--meteor-size) * 0.035)) solid
color-mix(in srgb, var(--gift-jade) 72%, white);
border-radius: 50%;
background:
radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.42), transparent 25%),
radial-gradient(circle, rgba(31, 125, 122, 0.94), rgba(2, 24, 39, 0.96));
box-shadow:
0 0 calc(var(--meteor-size) * 0.18) var(--gift-cyan),
0 0 calc(var(--meteor-size) * 0.55) color-mix(in srgb, var(--gift-jade) 56%, transparent),
inset 0 0 calc(var(--meteor-size) * 0.18) rgba(223, 255, 247, 0.45);
}
.meteor-core::after {
content: '';
position: absolute;
inset: 5%;
border: 1px solid rgba(255, 236, 173, 0.6);
border-radius: inherit;
box-shadow: inset 0 0 12px rgba(255, 238, 185, 0.25);
}
.meteor-core img {
width: 82%;
height: 82%;
object-fit: contain;
opacity: 0.96;
filter: blur(0.55px) saturate(0.92) brightness(1.08)
drop-shadow(0 0 7px rgba(255, 238, 178, 0.72)) drop-shadow(0 0 13px rgba(225, 255, 248, 0.48));
}
.meteor-fallback {
color: var(--gift-gold);
font-size: calc(var(--meteor-size) * 0.55);
text-shadow: 0 0 12px var(--gift-cyan);
}
.meteor-tail {
position: absolute;
top: 38%;
right: 48%;
z-index: 1;
width: calc(var(--meteor-size) * 5.4);
height: 24%;
border-radius: 100% 0 0 100%;
opacity: var(--trail-opacity);
background:
linear-gradient(
90deg,
transparent 0%,
rgba(255, 190, 54, 0.025) 18%,
rgba(255, 199, 67, 0.14) 42%,
rgba(255, 211, 91, 0.5) 72%,
rgba(255, 244, 185, 0.96) 100%
),
linear-gradient(90deg, transparent 8%, rgba(255, 174, 26, 0.08) 48%, #ffd96d 100%);
filter: blur(calc(var(--meteor-size) * 0.022))
drop-shadow(0 0 calc(var(--meteor-size) * 0.08) rgba(255, 196, 54, 0.72));
clip-path: polygon(0 50%, 100% 8%, 100% 92%);
}
.meteor-tail::after {
content: '';
position: absolute;
inset: 39% 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 205, 75, 0.12) 48%,
rgba(255, 249, 207, 0.92)
);
box-shadow: 0 0 calc(var(--meteor-size) * 0.06) rgba(255, 218, 100, 0.72);
}
.tier-high .meteor-tail {
background: linear-gradient(
90deg,
transparent,
rgba(255, 184, 33, 0.12) 35%,
rgba(255, 211, 77, 0.62) 74%,
#fff2ac
);
}
.tier-featured .meteor-tail {
background: linear-gradient(
90deg,
transparent,
rgba(255, 171, 19, 0.16) 28%,
rgba(255, 207, 62, 0.72) 70%,
#fff9cf
);
filter: blur(calc(var(--meteor-size) * 0.02))
drop-shadow(0 0 calc(var(--meteor-size) * 0.12) rgba(255, 207, 55, 0.88));
}
.meteor-spark {
position: absolute;
z-index: 2;
width: 12%;
height: 12%;
background: var(--gift-gold);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 5px var(--gift-cyan));
animation: gift-meteor-sparkle 720ms ease-in-out infinite alternate;
}
.meteor-spark-a {
top: -8%;
right: -15%;
}
.meteor-spark-b {
right: 14%;
bottom: -14%;
width: 8%;
height: 8%;
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 {
animation: none;
}
@keyframes gift-meteor-flight {
0% {
opacity: 0;
transform: translate3d(calc(var(--meteor-size) * -5.5), 0, 0) scale(0.76) rotate(-5deg);
}
8% {
opacity: 1;
}
88% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate3d(calc(100vw + var(--meteor-size) * 2), var(--meteor-drift), 0) scale(1.06)
rotate(8deg);
}
}
@keyframes gift-meteor-sparkle {
from {
opacity: 0.25;
transform: rotate(0) scale(0.55);
}
to {
opacity: 1;
transform: rotate(45deg) scale(1.2);
}
}
@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 {
animation: none;
}
}
+369
View File
@@ -0,0 +1,369 @@
/** Full-viewport gift meteor and guard celebration renderer. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGiftEffectSettings } from './api'
import { getGiftEffectTheme, giftThemeVariables } from './giftThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftEffectSettings } from './types'
import type { GiftEffectSettings, MeteorTierSettings } from './types'
type Viewer = { uid?: string; name?: string }
type Gift = {
name?: string
totalPrice?: number
priceCny?: number
imageUrl?: string
animationUrl?: string
}
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'
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 tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier {
if (effect.previewTier) return effect.previewTier
const value = effect.payload.gift?.totalPrice ?? 0
if (value >= settings.featuredValueThreshold) return 'featured'
if (value >= settings.highValueThreshold) return 'high'
return 'normal'
}
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 totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000
return {
id: `preview-${mode}-${nonce}`,
type: 'live.gift',
payload: {
viewer,
quantity: 1,
gift: {
name: translate(`gift.preview.${mode}`),
totalPrice,
priceCny: totalPrice / 1000,
imageUrl: '/pwa/icon-192.png',
},
},
}
}
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()
return {
id: envelope.id,
kind,
payload: envelope.payload ?? {},
receivedAt: now,
expiresAt: now + (kind === 'guard' ? guardDuration + 1_500 : 45_000),
previewTier,
}
}
function useGiftEffects(
preview: boolean,
stream: ComponentStream | undefined,
previewSettings: GiftEffectSettings | undefined,
previewMode: GiftEffectPreviewMode,
previewNonce: number,
language: string,
) {
const [settings, setSettings] = useState(defaultGiftEffectSettings)
const [effects, setEffects] = useState<VisualEffect[]>([])
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 EffectEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
const next = normalizeGiftEffectSettings(envelope.payload?.settings)
settingsRef.current = next
setSettings(next)
continue
}
const effect = toVisualEffect(envelope, settingsRef.current.guardEffectDurationMs)
if (!effect) continue
setEffects(current =>
[effect, ...current.filter(item => item.id !== effect.id)].slice(
0,
settingsRef.current.maxConcurrentEffects,
),
)
}
}, [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])
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 }
}
function GiftImage({ gift }: { gift: Gift }) {
const [source, setSource] = useState(gift.animationUrl || gift.imageUrl || '')
useEffect(
() => setSource(gift.animationUrl || gift.imageUrl || ''),
[gift.animationUrl, gift.imageUrl],
)
if (!source) return <span className="meteor-fallback">✦</span>
return (
<img
src={source}
alt={gift.name || translate('common.gift')}
decoding="async"
referrerPolicy="no-referrer"
onError={() => {
if (gift.imageUrl && source !== gift.imageUrl) setSource(gift.imageUrl)
else setSource('')
}}
/>
)
}
function MeteorBurst({
effect,
tier,
tierSettings,
scale,
viewportWidth,
trailIntensity,
lowPerformance,
}: {
effect: VisualEffect
tier: GiftTier
tierSettings: MeteorTierSettings
scale: number
viewportWidth: number
trailIntensity: number
lowPerformance: boolean
}) {
const gift = effect.payload.gift ?? {}
const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count
const size = Math.max(20, tierSettings.size * scale)
const speed = Math.max(80, tierSettings.speed * scale)
return (
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:${index}`)
const startY = 7 + (seed % 78)
const drift = ((seed >>> 8) % 37) - 18
const delay = index * 95 + ((seed >>> 16) % 260)
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
return (
<div
className="gift-meteor"
key={`${effect.id}:${index}`}
style={
{
['--meteor-size' as string]: `${size}px`,
['--meteor-y' as string]: `${startY}%`,
['--meteor-drift' as string]: `${drift}vh`,
['--meteor-delay' as string]: `${delay}ms`,
['--meteor-duration' as string]: `${duration}ms`,
['--trail-opacity' as string]: `${trailIntensity / 100}`,
} as CSSProperties
}
>
<i className="meteor-tail" />
<i className="meteor-spark meteor-spark-a" />
<i className="meteor-spark meteor-spark-b" />
<span className="meteor-core">
<GiftImage gift={gift} />
</span>
</div>
)
})}
</div>
)
}
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,
previewMode = 'high',
previewNonce = 0,
stream,
}: {
preview?: boolean
previewSettings?: GiftEffectSettings
previewMode?: GiftEffectPreviewMode
previewNonce?: number
stream?: ComponentStream
}) {
const { language } = useI18n()
const remote = useGiftEffects(
preview,
stream,
previewSettings,
previewMode,
previewNonce,
language,
)
const settings = previewSettings ?? remote.settings
const theme = getGiftEffectTheme(settings.themeId)
const root = useRef<HTMLElement>(null)
const [bounds, setBounds] = useState({ width: 1920, height: 1080 })
useEffect(() => {
if (!root.current) return
const observer = new ResizeObserver(([entry]) => {
setBounds({ width: entry.contentRect.width, height: entry.contentRect.height })
})
observer.observe(root.current)
return () => observer.disconnect()
}, [])
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')
return (
<main
ref={root}
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
style={giftThemeVariables(theme)}
>
<div className="meteor-sky" aria-live="polite">
{remote.effects
.filter(effect => effect.kind === 'gift')
.map(effect => {
const tier = tierFor(effect, settings)
return (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
key={effect.id}
/>
)
})}
</div>
{guard && <GuardCelebration effect={guard} settings={settings} />}
</main>
)
}
+63
View File
@@ -0,0 +1,63 @@
import type { CSSProperties } from 'react'
import type { GiftEffectThemeId } from './types'
export type GiftEffectTheme = {
id: GiftEffectThemeId
nameKey: string
descriptionKey: string
className: string
palette: {
jade: string
cyan: string
gold: string
rose: string
night: string
}
motion: {
meteor: string
guardReveal: string
starPulse: string
}
}
export const giftEffectThemes: readonly GiftEffectTheme[] = [
{
id: 'jade-starfall',
nameKey: 'gift.theme.jade_starfall.name',
descriptionKey: 'gift.theme.jade_starfall.description',
className: 'gift-theme-jade-starfall',
palette: {
jade: '#72f3d8',
cyan: '#a9fff2',
gold: '#ffe7a4',
rose: '#ffd0e5',
night: '#020c18',
},
motion: {
meteor: 'gift-meteor-flight',
guardReveal: 'gift-guard-reveal',
starPulse: 'gift-star-pulse',
},
},
]
export function getGiftEffectTheme(id: unknown): GiftEffectTheme {
return giftEffectThemes.find(theme => theme.id === id) ?? giftEffectThemes[0]
}
export function normalizeGiftEffectThemeId(id: unknown): GiftEffectThemeId {
return getGiftEffectTheme(id).id
}
export function giftThemeVariables(theme: GiftEffectTheme): CSSProperties {
return {
['--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,
}
}
+3
View File
@@ -17,6 +17,7 @@ import {
InvitationsPage,
SongRequestsPage,
} from './control'
import { GiftEffectOverlay } from './giftEffect'
import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
import { SongRequestOverlay } from './songOverlay'
@@ -25,6 +26,7 @@ import { I18nProvider, translate, useI18n } from './i18n'
import type { Session } from './types'
import './style.css'
import './control.css'
import './giftEffect.css'
import './song.css'
function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) {
@@ -38,6 +40,7 @@ function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken
if (stream.connection === 'denied')
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 === 'danmaku_overlay' || stream.componentKind === 'danmaku')
return <Overlay stream={stream} />
return <main className="obs-status pending">{t('main.obs_connecting')}</main>
+38 -1
View File
@@ -71,7 +71,44 @@ export const defaultSongRequestSettings: SongRequestSettings = {
requestCooldownSeconds: 0,
}
export type ComponentSettings = OverlaySettings | SongRequestSettings
export type GiftEffectThemeId = 'jade-starfall'
export type MeteorTierSettings = {
count: number
size: number
speed: number
}
/** Full-viewport visual settings for gift meteors and guard celebrations. */
export type GiftEffectSettings = {
themeId: GiftEffectThemeId
highValueThreshold: number
featuredValueThreshold: number
normal: MeteorTierSettings
high: MeteorTierSettings
featured: MeteorTierSettings
trailIntensity: number
guardStarCount: number
guardEffectDurationMs: number
maxConcurrentEffects: number
lowPerformanceMode: boolean
}
export const defaultGiftEffectSettings: GiftEffectSettings = {
themeId: 'jade-starfall',
highValueThreshold: 10_000,
featuredValueThreshold: 100_000,
normal: { count: 3, size: 88, speed: 560 },
high: { count: 6, size: 126, speed: 720 },
featured: { count: 10, size: 168, speed: 880 },
trailIntensity: 78,
guardStarCount: 48,
guardEffectDurationMs: 5_200,
maxConcurrentEffects: 8,
lowPerformanceMode: false,
}
export type ComponentSettings = OverlaySettings | SongRequestSettings | GiftEffectSettings
export type SongRequester = { uid: string; name: string }
+1
View File
@@ -22,6 +22,7 @@ crate 导出。
| `realtime` | account event routing 与 component-scoped fanout |
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
## 重要不变量
@@ -0,0 +1,7 @@
-- Every account owns exactly one built-in full-screen gift-effect component.
-- Existing accounts are backfilled from the validated Rust defaults during
-- startup; the partial index keeps concurrent starts idempotent.
CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_gift_effect
ON component_instances(owner_user_id, kind)
WHERE kind = 'gift_effect';
+2 -1
View File
@@ -81,7 +81,7 @@ impl AppState {
let repository =
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
repository
.ensure_song_request_components()
.ensure_builtin_components()
.await
.map_err(|error| error.to_string())?;
repository
@@ -291,6 +291,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
8_i32,
include_str!("../migrations/008_account_language.sql"),
),
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
] {
let applied = transaction
.query_one(
+18
View File
@@ -26,6 +26,7 @@ use uuid::Uuid;
use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
db::{Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
i18n,
overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -650,6 +651,23 @@ impl AuthService {
&[&user_id, &song_component_id],
)
.await?;
let gift_component_id = Uuid::new_v4();
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.expect("GiftEffectSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&gift_component_id,
&user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
transaction
.execute(
"DELETE FROM pending_registrations WHERE id=$1",
+26
View File
@@ -20,6 +20,7 @@ use uuid::Uuid;
use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind},
gift_effect::GiftEffectDefinition,
overlay::OverlaySettings,
song_request::{SongRequestDefinition, SongRequestProjection},
};
@@ -382,6 +383,12 @@ impl ComponentRegistry {
)
.expect("built-in component kinds are unique");
registry
.register(
Arc::new(GiftEffectDefinition),
Arc::new(PassthroughProjection),
)
.expect("built-in component kinds are unique");
registry
}
pub fn register(
@@ -548,6 +555,25 @@ mod tests {
assert!(!subscriptions.contains(LiveEventKind::Gift));
}
#[test]
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
let registry = ComponentRegistry::default();
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
let runtime = registry.runtime("gift_effect").unwrap();
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"gift_effect",
"礼物星雨",
1,
runtime.definition().default_settings(),
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn duplicate_component_kinds_are_rejected() {
let registry = ComponentRegistry::default();
+178
View File
@@ -0,0 +1,178 @@
//! Full-screen gift and membership effect component.
//!
//! The component is deliberately passive: it subscribes to canonical gift and
//! guard-purchase events and projects them to its own authenticated OBS stream.
//! All visual differentiation is settings-driven in the browser, so receiving
//! an effect never creates database writes or depends on an OBS connection.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
components::{ComponentDefinition, ComponentError, EventSubscription},
domain::LiveEventKind,
};
pub const GIFT_EFFECT_KIND: &str = "gift_effect";
pub const GIFT_EFFECT_NAME: &str = "礼物星雨";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GiftEffectThemeId {
#[default]
JadeStarfall,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeteorTierSettings {
pub count: u8,
pub size: u16,
pub speed: u16,
}
impl MeteorTierSettings {
fn sanitize(mut self) -> Self {
self.count = self.count.clamp(1, 24);
self.size = self.size.clamp(24, 480);
self.speed = self.speed.clamp(100, 2_500);
self
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftEffectSettings {
#[serde(default)]
pub theme_id: GiftEffectThemeId,
pub high_value_threshold: i64,
pub featured_value_threshold: i64,
pub normal: MeteorTierSettings,
pub high: MeteorTierSettings,
pub featured: MeteorTierSettings,
pub trail_intensity: u8,
pub guard_star_count: u8,
pub guard_effect_duration_ms: u16,
pub max_concurrent_effects: u8,
pub low_performance_mode: bool,
}
impl Default for GiftEffectSettings {
fn default() -> Self {
Self {
theme_id: GiftEffectThemeId::default(),
high_value_threshold: 10_000,
featured_value_threshold: 100_000,
normal: MeteorTierSettings {
count: 3,
size: 88,
speed: 560,
},
high: MeteorTierSettings {
count: 6,
size: 126,
speed: 720,
},
featured: MeteorTierSettings {
count: 10,
size: 168,
speed: 880,
},
trail_intensity: 78,
guard_star_count: 48,
guard_effect_duration_ms: 5_200,
max_concurrent_effects: 8,
low_performance_mode: false,
}
}
}
impl GiftEffectSettings {
pub fn sanitize(mut self) -> Self {
self.high_value_threshold = self.high_value_threshold.max(0);
self.featured_value_threshold =
self.featured_value_threshold.max(self.high_value_threshold);
self.normal = self.normal.sanitize();
self.high = self.high.sanitize();
self.featured = self.featured.sanitize();
self.trail_intensity = self.trail_intensity.min(100);
self.guard_star_count = self.guard_star_count.clamp(8, 96);
self.guard_effect_duration_ms = self.guard_effect_duration_ms.clamp(1_000, 15_000);
self.max_concurrent_effects = self.max_concurrent_effects.clamp(1, 12);
self
}
}
pub struct GiftEffectDefinition;
impl GiftEffectDefinition {
fn parse(&self, settings: Value) -> Result<GiftEffectSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_EFFECT_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GiftEffectDefinition {
fn kind(&self) -> &'static str {
GIFT_EFFECT_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GiftEffectSettings::default())
.expect("GiftEffectSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
ComponentError::InvalidSettings {
kind: GIFT_EFFECT_KIND.to_owned(),
detail: error.to_string(),
}
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([
LiveEventKind::Gift,
LiveEventKind::GuardPurchase,
]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_bound_each_value_tier_and_keep_threshold_order() {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings["normal"]["count"] = Value::from(0);
settings["featured"]["size"] = Value::from(9_999);
settings["highValueThreshold"] = Value::from(50_000);
settings["featuredValueThreshold"] = Value::from(10_000);
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["normal"]["count"], 1);
assert_eq!(sanitized["featured"]["size"], 480);
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
}
#[test]
fn component_only_subscribes_to_durable_gifts_and_guards() {
let definition = GiftEffectDefinition;
let subscriptions = definition
.subscriptions(&definition.default_settings())
.unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
}
}
+21 -1
View File
@@ -34,7 +34,7 @@ use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
domain::{
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
GiftDetails, GiftEvent, LiveEvent, LiveEventPayload, PlatformViewer,
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer,
},
repository::{ComponentView, RepositoryError},
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
@@ -910,6 +910,14 @@ enum TestEventRequest {
battery: i32,
quantity: i32,
},
Guard {
uid: String,
name: String,
#[serde(rename = "guardName")]
guard_name: String,
quantity: i32,
price: i64,
},
}
async fn component_test_event(
@@ -959,6 +967,18 @@ async fn component_test_event(
source_event_id: format!("test-{}", Uuid::new_v4()),
})
}
TestEventRequest::Guard {
uid,
name,
guard_name,
quantity,
price,
} => LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
viewer: viewer(uid, name),
guard_name,
quantity: quantity.max(1),
price: price.max(0),
}),
};
let mut event = LiveEvent::new(
component.owner_id,
+1
View File
@@ -12,6 +12,7 @@ pub mod config;
pub mod credentials;
pub mod db;
pub mod domain;
pub mod gift_effect;
pub mod http_api;
pub mod i18n;
pub mod live;
+23 -6
View File
@@ -15,6 +15,7 @@ use uuid::Uuid;
use crate::{
components::{ComponentInstance, ComponentRegistry},
db::{ComponentRecord, Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
i18n,
realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -47,10 +48,10 @@ impl TenantRepository {
Ok(())
}
/// Ensure every active tenant has the built-in singleton song component.
/// The partial unique index makes this safe across concurrent application
/// starts; the state row is repaired independently for existing instances.
pub async fn ensure_song_request_components(&self) -> Result<(), RepositoryError> {
/// Ensure every active tenant has every required singleton component.
/// Partial unique indexes make concurrent starts idempotent. The song
/// component additionally owns a relational revision state row.
pub async fn ensure_builtin_components(&self) -> Result<(), RepositoryError> {
for tenant in self.db.list_active_tenants().await? {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
@@ -96,6 +97,22 @@ impl TenantRepository {
&[&tenant.user_id, &component_id],
)
.await?;
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
transaction.commit().await?;
}
Ok(())
@@ -131,7 +148,7 @@ impl TenantRepository {
// Every tenant receives this singleton during registration/startup.
// Keeping creation internal prevents a second instance from racing the
// partial unique index and turning a domain conflict into a DB error.
if kind == SONG_REQUEST_KIND {
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND) {
return Err(RepositoryError::Forbidden);
}
let runtime = self
@@ -192,7 +209,7 @@ impl TenantRepository {
.await?
.ok_or(RepositoryError::NotFound)?
.get(0);
if kind == SONG_REQUEST_KIND {
if matches!(kind.as_str(), SONG_REQUEST_KIND | GIFT_EFFECT_KIND) {
return Err(RepositoryError::Forbidden);
}
let changed = transaction
+3 -3
View File
@@ -1,7 +1,7 @@
# 组件开发指南
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay` 与
`song_request`,未来礼物墙或统计组件也应使用同一套契约。
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、 `song_request` 与
`gift_effect`,未来礼物墙或统计组件也应使用同一套契约。
## 一个组件由什么组成
@@ -56,4 +56,4 @@ Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与
[`song-request.md`](song-request.md)。
[`song-request.md`](song-request.md)、[`gift-effect.md`](gift-effect.md)。
+30
View File
@@ -0,0 +1,30 @@
# 全屏礼物特效组件
`gift_effect` 是每个账户自动拥有且不可删除的单例组件。它消费账户级直播源中的 `live.gift` 和
`live.guard.buy`,使用独立只读 token 作为透明 OBS 浏览器源;不订阅
`live.gift.combo`,避免一次连击重复触发完整特效。
## 展示行为
普通礼物从视口左侧生成一组带青玉、金色星尘拖尾的流星,礼物图片或 GIF 是流星主体,并从右侧完全飞出。原始价值按
`highValueThreshold` 和 `featuredValueThreshold`
分为普通、高价、特别高价三档,每档分别设置流星数量、基准尺寸和飞行速度。图片加载失败时使用内置星光图形,不依赖外部主题素材。
舰长、提督或总督使用同一个 `live.guard.buy`
路径,临时显示不透明星河、闪耀粒子、身份和用户昵称。多个普通礼物可并发;大航海展示选择最新事件。
## 尺寸与性能
OBS 建议从 `1920×1080` 开始,但渲染器没有固定画布。`ResizeObserver`
按浏览器源实际宽高缩放流星,宽屏、竖屏或自定义分辨率都保持全视口透明。控制台可调整:
- 三档数量、尺寸和速度;
- 拖尾强度和最大并发特效数;
- 大航海星数和全屏持续时间;
- 低性能模式(限制粒子和流星数量)。
## 扩展主题
后端设置的 `themeId` 使用稳定 kebab-case ID。前端主题统一在 `apps/overlay/src/giftThemes.ts`
注册颜色、动效标识和本地化资源键;主题 CSS 只作用于
`.gift-effect-overlay`,不得改变页面根背景或控制台。新增主题时同时扩展 Rust/TypeScript 类型、设置迁移、两种语言资源和降级测试。
+5
View File
@@ -110,6 +110,11 @@ close 结束连接。
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
`live.gift`;连击事件用于更新同一张视觉卡片。
`gift_effect` 只订阅 `live.gift` 与 `live.guard.buy`。礼物档位由 sanitized settings 中的
`highValueThreshold` 和 `featuredValueThreshold` 决定;浏览器使用 `gift.imageUrl` 或
`gift.animationUrl`
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
## 表情分段
`live.danmaku.payload.segments` 是判别联合:
+82 -2
View File
@@ -212,6 +212,7 @@ name = "简体中文"
"test.default_viewer" = "测试观众"
"test.default_text" = "今天也要闪闪发光!"
"test.default_gift" = "小花花"
"test.default_guard" = "舰长"
"test.sent" = "测试事件已发送到当前组件。"
"test.failed" = "测试事件发送失败"
"test.title" = "事件测试"
@@ -224,6 +225,9 @@ name = "简体中文"
"test.gift_name" = "礼物名称"
"test.quantity" = "数量"
"test.battery" = "电池数"
"test.guard" = "上舰"
"test.guard_name" = "舰队身份"
"test.price" = "价值(电池)"
"test.sending" = "正在发送…"
"test.trigger" = "触发测试事件"
"components.title" = "我的组件"
@@ -232,11 +236,13 @@ name = "简体中文"
"components.empty_description" = "账户初始化完成后,服务会为你创建默认弹幕姬。"
"components.danmaku_mark" = "弹"
"components.song_mark" = "歌"
"components.gift_mark" = "礼"
"components.generic_mark" = "件"
"components.danmaku_type" = "直播弹幕姬"
"components.song_type" = "直播点歌姬"
"components.gift_type" = "全屏礼物星雨"
"components.coming_soon" = "即将支持"
"components.future" = "礼物展示 · 点唱互动"
"components.future" = "更多主题 · 互动组件"
"components.settings_blocker" = "保存或还原当前组件设置"
"components.settings_load_failed" = "无法读取组件设置"
"components.load_failed" = "控制台数据加载失败"
@@ -247,6 +253,8 @@ name = "简体中文"
"components.danmaku_description" = "每一项都独立保存在当前用户的组件下。"
"components.song_settings" = "点歌姬设置"
"components.song_description" = "观众发送「点歌 歌名」入队,发送「打分 1-5」评价当前歌曲。"
"components.gift_settings" = "全屏礼物特效设置"
"components.gift_description" = "礼物化作流星横跨透明画布;舰长、提督和总督触发全屏星光献礼。"
"components.open_song_stats" = "打开点歌统计"
"components.generic_settings" = "组件设置"
"components.no_editor" = "该组件类型的设置编辑器尚未安装。"
@@ -351,6 +359,38 @@ name = "简体中文"
"song.overlay.queue_empty" = "下一首,会由谁来点呢?"
"theme.jade_scroll.name" = "青玉花卷"
"theme.jade_scroll.description" = "暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。"
"gift.theme.jade_starfall.name" = "青玉星落"
"gift.theme.jade_starfall.description" = "青玉、流光花卷与金色星尘组成的古风礼物流星。"
"gift.settings.thresholds" = "礼物价值分档(原始价格,1000 = 1 元)"
"gift.settings.high_threshold" = "高价值阈值"
"gift.settings.featured_threshold" = "特别高价值阈值"
"gift.settings.tiers" = "分档流星参数"
"gift.settings.tiers_description" = "流星大小以 1920×1080 为参考,其他 OBS 尺寸会按实际视口自适应。"
"gift.settings.tier.normal" = "普通礼物"
"gift.settings.tier.high" = "高价礼物"
"gift.settings.tier.featured" = "特别高价礼物"
"gift.settings.count" = "流星数量"
"gift.settings.size" = "礼物主体大小"
"gift.settings.speed" = "飞行速度"
"gift.settings.trail" = "拖尾强度"
"gift.settings.guard_stars" = "大航海星光数量"
"gift.settings.guard_duration" = "大航海全屏时长"
"gift.settings.concurrent" = "最大同时特效数"
"gift.preview.title" = "全屏礼物特效预览"
"gift.preview.description" = "预览按 16:9 缩放显示;OBS 浏览器源默认可用 1920×1080,也可使用任意分辨率。"
"gift.preview.normal_button" = "预览普通礼物"
"gift.preview.high_button" = "预览高价礼物"
"gift.preview.featured_button" = "预览特别礼物"
"gift.preview.guard_button" = "预览舰长特效"
"gift.preview.viewer" = "星光观众"
"gift.preview.normal" = "小花花"
"gift.preview.high" = "青玉献礼"
"gift.preview.featured" = "星河之梦"
"gift.preview.guard" = "舰长"
"gift.guard_aria" = "大航海全屏庆祝特效"
"gift.guard_salute" = "星河为你闪耀"
"gift.guard_title" = "{guard}·星光献礼"
"gift.guard_viewer" = "感谢 {viewer} 的守护"
"pwa.blocked" = "暂时不能更新,请先处理以下内容:\n\n{reasons}"
"pwa.confirm_update" = "更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?"
"pwa.offline" = "离线"
@@ -570,6 +610,7 @@ name = "English"
"test.default_viewer" = "Test viewer"
"test.default_text" = "Shine brightly today!"
"test.default_gift" = "Little flower"
"test.default_guard" = "Captain"
"test.sent" = "The test event was sent to this component."
"test.failed" = "Could not send the test event"
"test.title" = "Event testing"
@@ -582,6 +623,9 @@ name = "English"
"test.gift_name" = "Gift name"
"test.quantity" = "Quantity"
"test.battery" = "Battery value"
"test.guard" = "Guard purchase"
"test.guard_name" = "Guard tier"
"test.price" = "Value (battery)"
"test.sending" = "Sending…"
"test.trigger" = "Trigger test event"
"components.title" = "My components"
@@ -590,11 +634,13 @@ name = "English"
"components.empty_description" = "The service creates a default chat overlay after account initialization."
"components.danmaku_mark" = "Chat"
"components.song_mark" = "Song"
"components.gift_mark" = "Gift"
"components.generic_mark" = "App"
"components.danmaku_type" = "Live chat overlay"
"components.song_type" = "Song request overlay"
"components.gift_type" = "Full-screen gift starfall"
"components.coming_soon" = "Coming soon"
"components.future" = "Gift showcase · Song interaction"
"components.future" = "More themes · Interactive components"
"components.settings_blocker" = "save or revert the current component settings"
"components.settings_load_failed" = "Could not load component settings"
"components.load_failed" = "Could not load console data"
@@ -605,6 +651,8 @@ name = "English"
"components.danmaku_description" = "Every setting is stored independently on this user's component."
"components.song_settings" = "Song request settings"
"components.song_description" = "Viewers send “点歌 Song name” to queue a song and “打分 1-5” to rate the current song."
"components.gift_settings" = "Full-screen gift effect settings"
"components.gift_description" = "Gifts become meteors crossing a transparent canvas; Guard, Admiral, and Governor purchases trigger a full-screen starlight celebration."
"components.open_song_stats" = "Open song statistics"
"components.generic_settings" = "Component settings"
"components.no_editor" = "No settings editor is installed for this component kind."
@@ -709,6 +757,38 @@ name = "English"
"song.overlay.queue_empty" = "Who will request the next song?"
"theme.jade_scroll.name" = "Jade Blossom Scroll"
"theme.jade_scroll.description" = "Dark jade glass, classical floral patterns, starlight particles, and a horizontal scroll reveal."
"gift.theme.jade_starfall.name" = "Jade Starfall"
"gift.theme.jade_starfall.description" = "Traditional jade, luminous floral scrollwork, and golden stardust shape each gift meteor."
"gift.settings.thresholds" = "Gift value tiers (raw price, 1000 = CNY 1)"
"gift.settings.high_threshold" = "High-value threshold"
"gift.settings.featured_threshold" = "Featured-value threshold"
"gift.settings.tiers" = "Meteor settings by tier"
"gift.settings.tiers_description" = "Sizes use 1920×1080 as a reference; other OBS dimensions scale responsively to their actual viewport."
"gift.settings.tier.normal" = "Normal gift"
"gift.settings.tier.high" = "High-value gift"
"gift.settings.tier.featured" = "Featured gift"
"gift.settings.count" = "Meteor count"
"gift.settings.size" = "Gift core size"
"gift.settings.speed" = "Flight speed"
"gift.settings.trail" = "Trail intensity"
"gift.settings.guard_stars" = "Membership star count"
"gift.settings.guard_duration" = "Membership full-screen duration"
"gift.settings.concurrent" = "Maximum concurrent effects"
"gift.preview.title" = "Full-screen gift effect preview"
"gift.preview.description" = "The preview is scaled to 16:9. OBS browser sources may use the 1920×1080 default or any other resolution."
"gift.preview.normal_button" = "Preview normal gift"
"gift.preview.high_button" = "Preview high-value gift"
"gift.preview.featured_button" = "Preview featured gift"
"gift.preview.guard_button" = "Preview Guard effect"
"gift.preview.viewer" = "Starlight viewer"
"gift.preview.normal" = "Little flower"
"gift.preview.high" = "Jade offering"
"gift.preview.featured" = "Dream of the Stars"
"gift.preview.guard" = "Guard"
"gift.guard_aria" = "Full-screen membership celebration"
"gift.guard_salute" = "THE STARS SHINE FOR YOU"
"gift.guard_title" = "{guard} · STARLIGHT TRIBUTE"
"gift.guard_viewer" = "Thank you, {viewer}, for your support"
"pwa.blocked" = "The update is blocked. Finish these items first:\n\n{reasons}"
"pwa.confirm_update" = "Updating refreshes the console. Save settings, invitations, recovery codes, and newly rotated OBS tokens first. Update now?"
"pwa.offline" = "Offline"