add full-screen gift effects
This commit is contained in:
@@ -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 源替换为上述新地址;旧令牌一旦轮换便立即失效。
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+322
-13
@@ -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,7 +1201,9 @@ function ComponentList({
|
||||
? translate('components.danmaku_mark')
|
||||
: isSongRequestKind(component.kind)
|
||||
? translate('components.song_mark')
|
||||
: translate('components.generic_mark')}
|
||||
: isGiftEffectKind(component.kind)
|
||||
? translate('components.gift_mark')
|
||||
: translate('components.generic_mark')}
|
||||
</span>
|
||||
<span>
|
||||
<b>
|
||||
@@ -935,14 +1211,18 @@ function ComponentList({
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(component.kind)
|
||||
? translate('components.song_type')
|
||||
: component.name}
|
||||
: isGiftEffectKind(component.kind)
|
||||
? translate('components.gift_type')
|
||||
: component.name}
|
||||
</b>
|
||||
<small>
|
||||
{isDanmakuKind(component.kind)
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(component.kind)
|
||||
? translate('components.song_type')
|
||||
: component.kind}
|
||||
: isGiftEffectKind(component.kind)
|
||||
? translate('components.gift_type')
|
||||
: component.kind}
|
||||
</small>
|
||||
</span>
|
||||
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
|
||||
@@ -1000,7 +1280,9 @@ export function ComponentsPage({
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
|
||||
const next = isSongRequestKind(component.kind)
|
||||
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
|
||||
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
: isGiftEffectKind(component.kind)
|
||||
? { ...defaultGiftEffectSettings, ...normalizeGiftEffectSettings(payload) }
|
||||
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
savedSettingsRef.current = JSON.stringify(next)
|
||||
setSettings(next)
|
||||
} catch (reason) {
|
||||
@@ -1071,7 +1353,9 @@ export function ComponentsPage({
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
|
||||
const next = isSongRequestKind(selected.kind)
|
||||
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
|
||||
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
: isGiftEffectKind(selected.kind)
|
||||
? { ...defaultGiftEffectSettings, ...normalizeGiftEffectSettings(payload) }
|
||||
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
savedSettingsRef.current = JSON.stringify(next)
|
||||
setSettings(next)
|
||||
setFlash({ kind: 'success', text: translate('components.settings_saved') })
|
||||
@@ -1105,14 +1389,18 @@ export function ComponentsPage({
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(selected.kind)
|
||||
? translate('components.song_type')
|
||||
: selected.kind}
|
||||
: isGiftEffectKind(selected.kind)
|
||||
? translate('components.gift_type')
|
||||
: selected.kind}
|
||||
</p>
|
||||
<h1>
|
||||
{isDanmakuKind(selected.kind)
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(selected.kind)
|
||||
? translate('components.song_type')
|
||||
: selected.name}
|
||||
: isGiftEffectKind(selected.kind)
|
||||
? translate('components.gift_type')
|
||||
: selected.name}
|
||||
</h1>
|
||||
</div>
|
||||
<span
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 类型、设置迁移、两种语言资源和降级测试。
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user