add configurable gift menu overlays

Add tenant-scoped gift menu settings, catalog-backed triggers, infinite OBS rendering, and guard assets. Normalize legacy and protobuf gift values for blind-box, battery-tier, and transaction-aware matching.
This commit is contained in:
2026-07-21 19:41:49 -07:00
parent c4eca7b8bf
commit 716c6f3f2f
29 changed files with 2764 additions and 91 deletions
+17 -14
View File
@@ -18,20 +18,22 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
## 文件职责
| 文件 | 职责 |
| --------------------- | ---------------------------------------------------- |
| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer |
| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 |
| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 |
| `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 |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
| 文件 | 职责 |
| ----------------------- | ---------------------------------------------------- |
| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer |
| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 |
| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 |
| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 |
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
| `src/giftEffect.tsx` | 礼物流星、大航海全屏庆祝与视口自适应渲染 |
| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 |
| `src/giftMenu.tsx` | 礼物菜单无限循环、触发定位与高亮 reducer |
| `src/giftMenuThemes.ts` | 可扩展礼物菜单主题注册表 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/i18n.tsx` | TOML 语言资源、浏览器回退和运行时切换 |
| `src/types.ts` | sanitized API view model 与 overlay settings |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
## Secret 与状态
@@ -73,3 +75,4 @@ YAML 和项目文档。
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
- [点歌姬组件](../../docs/components/song-request.md)
- [全屏礼物特效](../../docs/components/gift-effect.md)
- [礼物菜单组件](../../docs/components/gift-menu.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+89
View File
@@ -12,6 +12,9 @@ import type {
ComponentSummary,
CookieCloudSource,
GiftEffectSettings,
GiftCatalogItem,
GiftMenuItem,
GiftMenuSettings,
Invitation,
OverlaySettings,
Session,
@@ -22,8 +25,10 @@ import type {
} from './types'
import { normalizeThemeId } from './themes'
import { normalizeGiftEffectThemeId } from './giftThemes'
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
import {
defaultGiftEffectSettings,
defaultGiftMenuSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
} from './types'
@@ -236,6 +241,90 @@ export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings
}
}
function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined {
const item = object(value)
const trigger = object(item.trigger)
if (typeof item.id !== 'string' || typeof item.description !== 'string') return undefined
if (trigger.kind === 'gift') {
const giftId = Number(trigger.giftId)
if (!Number.isSafeInteger(giftId) || giftId <= 0 || typeof trigger.giftName !== 'string')
return undefined
return {
id: item.id,
description: item.description,
trigger: {
kind: 'gift',
giftId,
giftName: trigger.giftName,
unitPrice: Number(trigger.unitPrice ?? 0),
imageUrl: typeof trigger.imageUrl === 'string' ? trigger.imageUrl : undefined,
},
}
}
if (
trigger.kind === 'guard' &&
['captain', 'admiral', 'governor'].includes(String(trigger.level))
) {
return {
id: item.id,
description: item.description,
trigger: {
kind: 'guard',
level: trigger.level as 'captain' | 'admiral' | 'governor',
},
}
}
const amount = Number(trigger.amount)
if (trigger.kind === 'battery' && Number.isSafeInteger(amount) && amount > 0) {
return {
id: item.id,
description: item.description,
trigger: { kind: 'battery', amount },
}
}
return undefined
}
export function normalizeGiftMenuSettings(value: unknown): GiftMenuSettings {
const root = object(value)
const settings = object(root.settings ?? value)
const items = Array.isArray(settings.items)
? settings.items.map(normalizeGiftMenuItem).filter(item => item !== undefined)
: []
return {
...defaultGiftMenuSettings,
...(settings as Partial<GiftMenuSettings>),
themeId: normalizeGiftMenuThemeId(settings.themeId),
items,
}
}
export function normalizeGiftCatalog(value: unknown): GiftCatalogItem[] {
const root = object(value)
if (!Array.isArray(root.gifts)) return []
return root.gifts.flatMap(candidate => {
const gift = object(candidate)
const id = Number(gift.id)
if (!Number.isSafeInteger(id) || id <= 0 || typeof gift.name !== 'string') return []
return [
{
id,
name: gift.name,
coinType: String(gift.coinType ?? 'gold'),
batteryValue: Number(gift.batteryValue ?? Number(gift.unitPrice ?? 0) / 100),
unitPrice: Number(gift.unitPrice ?? 0),
imageUrl:
typeof gift.imageUrl === 'string'
? gift.imageUrl
: typeof gift.animationUrl === 'string'
? gift.animationUrl
: undefined,
animationUrl: typeof gift.animationUrl === 'string' ? gift.animationUrl : undefined,
},
]
})
}
export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined {
const item = object(value)
const requester = object(item.requester)
+147
View File
@@ -217,6 +217,153 @@
}
}
.gift-menu-settings-editor {
gap: 22px;
}
.gift-catalog-status {
display: grid;
align-content: start;
justify-items: start;
gap: 7px;
padding: 14px;
border: 1px solid rgba(102, 229, 211, 0.2);
border-radius: 14px;
background: rgba(2, 31, 43, 0.48);
}
.gift-catalog-status span {
color: var(--muted);
}
.gift-menu-builder {
display: grid;
grid-template-columns:
minmax(140px, 0.7fr) minmax(190px, 1fr) minmax(220px, 1.4fr)
auto;
align-items: end;
gap: 12px;
}
.gift-menu-builder legend {
padding: 0 8px;
color: var(--accent);
font-weight: 700;
}
.gift-menu-builder label {
display: grid;
gap: 7px;
}
.gift-menu-item-editor-list {
display: grid;
gap: 8px;
}
.gift-menu-item-editor {
display: grid;
min-width: 0;
grid-template-columns: 34px 52px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(101, 226, 211, 0.2);
border-radius: 13px;
background: linear-gradient(105deg, rgba(4, 36, 49, 0.72), rgba(8, 63, 65, 0.42));
}
.gift-menu-item-editor > img,
.gift-menu-editor-mark {
display: grid;
width: 46px;
height: 46px;
place-items: center;
object-fit: contain;
border: 1px solid rgba(255, 224, 138, 0.3);
border-radius: 50%;
background: rgba(3, 25, 37, 0.7);
}
.gift-menu-editor-index {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.gift-menu-item-editor > div:not(.gift-menu-item-actions) {
display: grid;
min-width: 0;
gap: 3px;
}
.gift-menu-item-editor > div span {
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.gift-menu-item-actions {
display: flex;
gap: 6px;
}
.gift-menu-item-actions button {
min-width: 38px;
padding: 7px 10px;
}
.gift-menu-renderer-settings {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.gift-menu-preview-viewport {
width: 100%;
height: clamp(260px, 38vw, 460px);
overflow: hidden;
border: 1px dashed rgba(111, 240, 216, 0.55);
border-radius: 15px;
background-color: #02101a;
background-image:
linear-gradient(45deg, rgba(72, 161, 158, 0.08) 25%, transparent 25%),
linear-gradient(-45deg, rgba(72, 161, 158, 0.08) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(72, 161, 158, 0.08) 75%),
linear-gradient(-45deg, transparent 75%, rgba(72, 161, 158, 0.08) 75%);
background-position:
0 0,
0 12px,
12px -12px,
-12px 0;
background-size: 24px 24px;
}
@media (max-width: 900px) {
.gift-menu-builder,
.gift-menu-renderer-settings {
grid-template-columns: 1fr 1fr;
}
.gift-menu-description-field {
grid-column: 1 / -1;
}
}
@media (max-width: 620px) {
.gift-menu-builder,
.gift-menu-renderer-settings {
grid-template-columns: 1fr;
}
.gift-menu-item-editor {
grid-template-columns: 28px 46px minmax(0, 1fr);
}
.gift-menu-item-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
html,
body,
#root,
+579 -31
View File
@@ -15,7 +15,9 @@ import {
errorMessage,
json,
normalizeComponents,
normalizeGiftCatalog,
normalizeGiftEffectSettings,
normalizeGiftMenuSettings,
normalizeInvitations,
normalizeSettings,
normalizeSongRequestPage,
@@ -24,14 +26,17 @@ import {
} from './api'
import { Overlay } from './overlay'
import { GiftEffectOverlay } from './giftEffect'
import { GiftMenuOverlay } from './giftMenu'
import type { GiftEffectPreviewMode } from './giftEffect'
import { getGiftEffectTheme, giftEffectThemes } from './giftThemes'
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemes } from './giftMenuThemes'
import { PwaControls, usePwaUpdateBlocker } from './pwa'
import { SongRequestOverlay } from './songOverlay'
import { getOverlayTheme, overlayThemes } from './themes'
import { currentLanguage, LanguageSelect, translate, useI18n } from './i18n'
import {
defaultGiftEffectSettings,
defaultGiftMenuSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
} from './types'
@@ -41,6 +46,9 @@ import type {
ComponentSummary,
CookieCloudSource,
GiftEffectSettings,
GiftCatalogItem,
GiftMenuItem,
GiftMenuSettings,
Invitation,
OverlaySettings,
SongRequestItem,
@@ -51,7 +59,12 @@ import type {
const previewPresets = [
{ id: 'narrow', labelKey: 'preview.narrow', width: 360, height: 600 },
{ id: 'portrait', labelKey: 'preview.portrait', width: 440, height: 760 },
{ id: 'hd-portrait', labelKey: 'preview.hd_portrait', width: 600, height: 1080 },
{
id: 'hd-portrait',
labelKey: 'preview.hd_portrait',
width: 600,
height: 1080,
},
{ id: 'horizontal', labelKey: 'preview.horizontal', width: 720, height: 320 },
]
@@ -67,6 +80,10 @@ function isGiftEffectKind(kind: string): boolean {
return kind === 'gift_effect'
}
function isGiftMenuKind(kind: string): boolean {
return kind === 'gift_menu'
}
type Flash = { kind: 'success' | 'error'; text: string } | undefined
function Panel({
@@ -749,6 +766,423 @@ function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) {
)
}
function GiftMenuSettingsEditor({
componentId,
settings,
onChange,
onSave,
saving,
}: {
componentId: string
settings: GiftMenuSettings
onChange: (settings: GiftMenuSettings) => void
onSave: () => Promise<void>
saving: boolean
}) {
const [catalog, setCatalog] = useState<GiftCatalogItem[]>([])
const [catalogBusy, setCatalogBusy] = useState(false)
const [catalogError, setCatalogError] = useState('')
const [triggerKind, setTriggerKind] = useState<'gift' | 'guard' | 'battery'>('gift')
const [giftId, setGiftId] = useState('')
const [guardLevel, setGuardLevel] = useState<'captain' | 'admiral' | 'governor'>('captain')
const [battery, setBattery] = useState(150)
const [description, setDescription] = useState('')
const [draftError, setDraftError] = useState('')
const theme = getGiftMenuTheme(settings.themeId)
const edit = <K extends keyof GiftMenuSettings>(key: K, value: GiftMenuSettings[K]) =>
onChange({ ...settings, [key]: value })
const loadCatalog = useCallback(
async (refresh: boolean) => {
setCatalogBusy(true)
setCatalogError('')
try {
const path = `/api/v1/components/${encodeURIComponent(componentId)}/gift-catalog${refresh ? '/refresh' : ''}`
const payload = await api<unknown>(path, refresh ? json('POST') : undefined)
const gifts = normalizeGiftCatalog(payload)
setCatalog(gifts)
setGiftId(current => current || String(gifts[0]?.id ?? ''))
} catch (reason) {
setCatalogError(errorMessage(reason, translate('gift_menu.catalog.failed')))
} finally {
setCatalogBusy(false)
}
},
[componentId],
)
useEffect(() => {
void loadCatalog(false)
}, [loadCatalog])
const addItem = () => {
setDraftError('')
const explanation = description.trim()
if (!explanation) {
setDraftError(translate('gift_menu.editor.description_required'))
return
}
let item: GiftMenuItem | undefined
if (triggerKind === 'gift') {
const gift = catalog.find(candidate => candidate.id === Number(giftId))
if (!gift) {
setDraftError(translate('gift_menu.editor.gift_required'))
return
}
item = {
id: crypto.randomUUID(),
description: explanation,
trigger: {
kind: 'gift',
giftId: gift.id,
giftName: gift.name,
imageUrl: gift.imageUrl,
unitPrice: gift.unitPrice,
},
}
} else if (triggerKind === 'guard') {
item = {
id: crypto.randomUUID(),
description: explanation,
trigger: { kind: 'guard', level: guardLevel },
}
} else {
item = {
id: crypto.randomUUID(),
description: explanation,
trigger: { kind: 'battery', amount: Math.max(1, Math.floor(battery)) },
}
}
const key = JSON.stringify(item.trigger)
if (settings.items.some(existing => JSON.stringify(existing.trigger) === key)) {
setDraftError(translate('gift_menu.editor.duplicate'))
return
}
edit('items', [...settings.items, item])
setDescription('')
}
const move = (index: number, direction: -1 | 1) => {
const target = index + direction
if (target < 0 || target >= settings.items.length) return
const items = [...settings.items]
;[items[index], items[target]] = [items[target], items[index]]
edit('items', items)
}
return (
<div className="settings-editor gift-menu-settings-editor">
<div className="field-grid two-columns">
<label>
{translate('settings.theme')}
<select
value={settings.themeId}
onChange={event => edit('themeId', event.target.value as GiftMenuSettings['themeId'])}
>
{giftMenuThemes.map(candidate => (
<option value={candidate.id} key={candidate.id}>
{translate(candidate.nameKey)}
</option>
))}
</select>
<small>{translate(theme.descriptionKey)}</small>
</label>
<div className="gift-catalog-status">
<b>{translate('gift_menu.catalog.title')}</b>
<span>
{catalogBusy
? translate('gift_menu.catalog.loading')
: translate('gift_menu.catalog.count', { count: catalog.length })}
</span>
<button
type="button"
className="secondary"
disabled={catalogBusy}
onClick={() => void loadCatalog(true)}
>
{translate('gift_menu.catalog.refresh')}
</button>
</div>
</div>
{catalogError && <div className="notice error">{catalogError}</div>}
<fieldset className="gift-menu-builder">
<legend>{translate('gift_menu.editor.add_title')}</legend>
<label>
{translate('gift_menu.editor.trigger_type')}
<select
value={triggerKind}
onChange={event => setTriggerKind(event.target.value as typeof triggerKind)}
>
<option value="gift">{translate('gift_menu.trigger.gift')}</option>
<option value="guard">{translate('gift_menu.trigger.guard')}</option>
<option value="battery">{translate('gift_menu.trigger.battery')}</option>
</select>
</label>
{triggerKind === 'gift' && (
<label>
{translate('gift_menu.editor.gift')}
<select
value={giftId}
disabled={!catalog.length}
onChange={event => setGiftId(event.target.value)}
>
{catalog.map(gift => (
<option value={gift.id} key={gift.id}>
{translate('gift_menu.editor.gift_option', {
name: gift.name,
battery: gift.batteryValue,
})}
</option>
))}
</select>
</label>
)}
{triggerKind === 'guard' && (
<label>
{translate('gift_menu.editor.guard_level')}
<select
value={guardLevel}
onChange={event => setGuardLevel(event.target.value as typeof guardLevel)}
>
{(['captain', 'admiral', 'governor'] as const).map(level => (
<option value={level} key={level}>
{translate(`gift_menu.guard.${level}`)}
</option>
))}
</select>
</label>
)}
{triggerKind === 'battery' && (
<label>
{translate('gift_menu.editor.battery_amount')}
<input
type="number"
min="1"
max="1000000000"
value={battery}
onChange={event => setBattery(+event.target.value)}
/>
</label>
)}
<label className="gift-menu-description-field">
{translate('gift_menu.editor.description')}
<input
maxLength={200}
placeholder={translate('gift_menu.editor.description_placeholder')}
value={description}
onChange={event => setDescription(event.target.value)}
/>
</label>
<button type="button" onClick={addItem}>
{translate('gift_menu.editor.add')}
</button>
</fieldset>
{draftError && <div className="notice error">{draftError}</div>}
<div className="gift-menu-item-editor-list">
{settings.items.length === 0 ? (
<div className="empty-state">{translate('gift_menu.editor.empty')}</div>
) : (
settings.items.map((item, index) => (
<div className="gift-menu-item-editor" key={item.id}>
<span className="gift-menu-editor-index">{index + 1}</span>
{item.trigger.kind === 'gift' && item.trigger.imageUrl ? (
<img src={item.trigger.imageUrl} alt="" referrerPolicy="no-referrer" />
) : item.trigger.kind === 'guard' ? (
<img src={giftMenuGuardIconUrl(item.trigger.level)} alt="" />
) : (
<span className="gift-menu-editor-mark">
{item.trigger.kind === 'battery'
? translate('gift_menu.battery_mark')
: translate('components.gift_mark')}
</span>
)}
<div>
<b>
{item.trigger.kind === 'gift'
? item.trigger.giftName
: item.trigger.kind === 'guard'
? translate(`gift_menu.guard.${item.trigger.level}`)
: translate('gift_menu.overlay.battery', {
amount: item.trigger.amount,
})}
</b>
<span>{item.description}</span>
</div>
<div className="gift-menu-item-actions">
<button
type="button"
className="secondary"
disabled={index === 0}
onClick={() => move(index, -1)}
aria-label={translate('gift_menu.editor.move_up')}
>
↑
</button>
<button
type="button"
className="secondary"
disabled={index === settings.items.length - 1}
onClick={() => move(index, 1)}
aria-label={translate('gift_menu.editor.move_down')}
>
↓
</button>
<button
type="button"
className="danger"
onClick={() =>
edit(
'items',
settings.items.filter(candidate => candidate.id !== item.id),
)
}
>
{translate('gift_menu.editor.remove')}
</button>
</div>
</div>
))
)}
</div>
<div className="slider-grid gift-menu-renderer-settings">
<label>
<span>
{translate('gift_menu.settings.visible_rows')} <output>{settings.visibleRows}</output>
</span>
<input
type="range"
min="1"
max="20"
value={settings.visibleRows}
onChange={event => edit('visibleRows', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift_menu.settings.row_height')}{' '}
<output>
{translate('gift_menu.unit.pixels', {
value: settings.rowHeight,
})}
</output>
</span>
<input
type="range"
min="44"
max="240"
step="2"
value={settings.rowHeight}
onChange={event => edit('rowHeight', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift_menu.settings.scroll_speed')}{' '}
<output>
{translate('gift_menu.unit.speed', {
value: settings.scrollSpeedPixelsPerSecond,
})}
</output>
</span>
<input
type="range"
min="0"
max="240"
value={settings.scrollSpeedPixelsPerSecond}
onChange={event => edit('scrollSpeedPixelsPerSecond', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift_menu.settings.highlight_duration')}{' '}
<output>
{translate('gift_menu.unit.seconds', {
value: (settings.highlightDurationMs / 1000).toFixed(1),
})}
</output>
</span>
<input
type="range"
min="600"
max="12000"
step="200"
value={settings.highlightDurationMs}
onChange={event => edit('highlightDurationMs', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift_menu.settings.font_scale')} <output>{settings.fontScale}%</output>
</span>
<input
type="range"
min="50"
max="220"
value={settings.fontScale}
onChange={event => edit('fontScale', +event.target.value)}
/>
</label>
<label>
<span>
{translate('gift_menu.settings.motion')} <output>{settings.motionIntensity}%</output>
</span>
<input
type="range"
min="0"
max="100"
value={settings.motionIntensity}
onChange={event => edit('motionIntensity', +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 GiftMenuPreview({ settings }: { settings: GiftMenuSettings }) {
const [triggered, setTriggered] = useState<string>()
const [nonce, setNonce] = useState(0)
const trigger = (id: string) => {
setTriggered(id)
setNonce(current => current + 1)
}
return (
<Panel
title={translate('gift_menu.preview.title')}
description={translate('gift_menu.preview.description')}
className="preview-panel"
>
<div className="gift-menu-preview-viewport">
<GiftMenuOverlay
preview
previewSettings={settings}
previewTriggeredItemId={triggered}
previewNonce={nonce}
onPreviewTrigger={trigger}
/>
</div>
</Panel>
)
}
function SourceEditor({
source,
onSaved,
@@ -797,7 +1231,10 @@ function SourceEditor({
setPassword('')
setFlash({ kind: 'success', text: translate('source.saved') })
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, translate('source.save_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('source.save_failed')),
})
} finally {
setBusy(false)
}
@@ -934,7 +1371,10 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) {
.catch(reason => {
if (cancelled) return
setState({ publicId: component.publicId, configured: false })
setFlash({ kind: 'error', text: errorMessage(reason, translate('obs.status_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('obs.status_failed')),
})
})
return () => {
cancelled = true
@@ -957,7 +1397,10 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) {
text: translate('obs.token_created'),
})
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, translate('obs.rotate_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('obs.rotate_failed')),
})
} finally {
setBusy(false)
}
@@ -1035,6 +1478,7 @@ function TestEvents({
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 [giftId, setGiftId] = useState('')
const [quantity, setQuantity] = useState(1)
const [battery, setBattery] = useState(100)
const [guardName, setGuardName] = useState(() => translate('test.default_guard'))
@@ -1054,13 +1498,23 @@ function TestEvents({
uid,
name,
...(kind === 'danmaku' ? { text } : {}),
...(kind === 'gift' ? { giftName, quantity, battery } : {}),
...(kind === 'gift'
? {
giftName,
quantity,
battery,
...(giftId ? { giftId: Number(giftId) } : {}),
}
: {}),
...(kind === 'guard' ? { guardName, quantity, price: guardPrice } : {}),
}),
)
setFlash({ kind: 'success', text: translate('test.sent') })
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, translate('test.failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('test.failed')),
})
} finally {
setBusy(false)
}
@@ -1102,6 +1556,15 @@ function TestEvents({
onChange={event => setGiftName(event.target.value)}
/>
</label>
<label>
{translate('test.gift_id')}
<input
inputMode="numeric"
placeholder={translate('test.gift_id_placeholder')}
value={giftId}
onChange={event => setGiftId(event.target.value)}
/>
</label>
<label>
{translate('test.quantity')}
<input
@@ -1203,7 +1666,9 @@ function ComponentList({
? translate('components.song_mark')
: isGiftEffectKind(component.kind)
? translate('components.gift_mark')
: translate('components.generic_mark')}
: isGiftMenuKind(component.kind)
? translate('components.gift_menu_mark')
: translate('components.generic_mark')}
</span>
<span>
<b>
@@ -1213,7 +1678,9 @@ function ComponentList({
? translate('components.song_type')
: isGiftEffectKind(component.kind)
? translate('components.gift_type')
: component.name}
: isGiftMenuKind(component.kind)
? translate('components.gift_menu_type')
: component.name}
</b>
<small>
{isDanmakuKind(component.kind)
@@ -1222,7 +1689,9 @@ function ComponentList({
? translate('components.song_type')
: isGiftEffectKind(component.kind)
? translate('components.gift_type')
: component.kind}
: isGiftMenuKind(component.kind)
? translate('components.gift_menu_type')
: component.kind}
</small>
</span>
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
@@ -1279,10 +1748,21 @@ export function ComponentsPage({
)
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
const next = isSongRequestKind(component.kind)
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
? {
...defaultSongRequestSettings,
...normalizeSongRequestSettings(payload),
}
: isGiftEffectKind(component.kind)
? { ...defaultGiftEffectSettings, ...normalizeGiftEffectSettings(payload) }
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
? {
...defaultGiftEffectSettings,
...normalizeGiftEffectSettings(payload),
}
: isGiftMenuKind(component.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
} catch (reason) {
@@ -1352,13 +1832,27 @@ export function ComponentsPage({
)
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
const next = isSongRequestKind(selected.kind)
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
? {
...defaultSongRequestSettings,
...normalizeSongRequestSettings(payload),
}
: isGiftEffectKind(selected.kind)
? { ...defaultGiftEffectSettings, ...normalizeGiftEffectSettings(payload) }
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
? {
...defaultGiftEffectSettings,
...normalizeGiftEffectSettings(payload),
}
: isGiftMenuKind(selected.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
setFlash({ kind: 'success', text: translate('components.settings_saved') })
setFlash({
kind: 'success',
text: translate('components.settings_saved'),
})
} catch (reason) {
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
setFlash({
@@ -1391,7 +1885,9 @@ export function ComponentsPage({
? translate('components.song_type')
: isGiftEffectKind(selected.kind)
? translate('components.gift_type')
: selected.kind}
: isGiftMenuKind(selected.kind)
? translate('components.gift_menu_type')
: selected.kind}
</p>
<h1>
{isDanmakuKind(selected.kind)
@@ -1400,7 +1896,9 @@ export function ComponentsPage({
? translate('components.song_type')
: isGiftEffectKind(selected.kind)
? translate('components.gift_type')
: selected.name}
: isGiftMenuKind(selected.kind)
? translate('components.gift_menu_type')
: selected.name}
</h1>
</div>
<span
@@ -1475,16 +1973,34 @@ export function ComponentsPage({
</Panel>
<GiftEffectPreview settings={settings as GiftEffectSettings} />
</>
) : isGiftMenuKind(selected.kind) && settings ? (
<>
<Panel
title={translate('components.gift_menu_settings')}
description={translate('components.gift_menu_description')}
>
<GiftMenuSettingsEditor
componentId={selected.id}
settings={settings as GiftMenuSettings}
onChange={next => setSettings(next)}
onSave={saveSettings}
saving={saving}
/>
</Panel>
<GiftMenuPreview settings={settings as GiftMenuSettings} />
</>
) : (
<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) || isGiftEffectKind(selected.kind)) && (
{(isDanmakuKind(selected.kind) ||
isGiftEffectKind(selected.kind) ||
isGiftMenuKind(selected.kind)) && (
<TestEvents
componentId={selected.id}
giftOnly={isGiftEffectKind(selected.kind)}
giftOnly={isGiftEffectKind(selected.kind) || isGiftMenuKind(selected.kind)}
key={`test-${selected.id}`}
/>
)}
@@ -1523,7 +2039,10 @@ export function AccountLiveSourcePage({
})
.catch(reason => {
if (!cancelled)
setFlash({ kind: 'error', text: errorMessage(reason, translate('account.load_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('account.load_failed')),
})
})
.finally(() => {
if (!cancelled) setLoading(false)
@@ -1609,7 +2128,10 @@ export function SongRequestsPage({
setActive(activePage)
setHistoryPage(normalizeSongRequestPage(historyPayload))
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, translate('song.queue_load_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('song.queue_load_failed')),
})
} finally {
loadingRef.current = false
}
@@ -1657,7 +2179,10 @@ export function SongRequestsPage({
})
await load()
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, translate('song.action_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('song.action_failed')),
})
} finally {
setBusyId('')
}
@@ -1736,7 +2261,9 @@ export function SongRequestsPage({
)}
</Panel>
<Panel
title={translate('song.queue_title', { count: active?.items.length ?? 0 })}
title={translate('song.queue_title', {
count: active?.items.length ?? 0,
})}
description={translate('song.queue_description')}
>
<div className="song-admin-list">
@@ -1842,14 +2369,26 @@ function formatDate(value?: string): string {
}).format(date)
}
function invitationStatus(invitation: Invitation): { label: string; className: string } {
function invitationStatus(invitation: Invitation): {
label: string
className: string
} {
if (invitation.revokedAt)
return { label: translate('invitation.status.revoked'), className: 'offline' }
return {
label: translate('invitation.status.revoked'),
className: 'offline',
}
if (invitation.expiresAt && new Date(invitation.expiresAt).getTime() <= Date.now())
return { label: translate('invitation.status.expired'), className: 'offline' }
return {
label: translate('invitation.status.expired'),
className: 'offline',
}
if (invitation.consumedAt)
return { label: translate('invitation.status.used'), className: 'offline' }
return { label: translate('invitation.status.available'), className: 'online' }
return {
label: translate('invitation.status.available'),
className: 'online',
}
}
export function InvitationsPage({
@@ -1875,7 +2414,10 @@ export function InvitationsPage({
useEffect(() => {
void load().catch(reason =>
setFlash({ kind: 'error', text: errorMessage(reason, translate('invitation.load_failed')) }),
setFlash({
kind: 'error',
text: errorMessage(reason, translate('invitation.load_failed')),
}),
)
}, [load])
@@ -1900,7 +2442,10 @@ export function InvitationsPage({
setFlash({ kind: 'success', text: translate('invitation.created') })
await load()
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, translate('invitation.create_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('invitation.create_failed')),
})
} finally {
setBusy(false)
}
@@ -1914,7 +2459,10 @@ export function InvitationsPage({
setFlash({ kind: 'success', text: translate('invitation.revoked') })
await load()
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, translate('invitation.revoke_failed')) })
setFlash({
kind: 'error',
text: errorMessage(reason, translate('invitation.revoke_failed')),
})
}
}
+389
View File
@@ -0,0 +1,389 @@
html,
body,
#root {
background: transparent;
}
.gift-menu-overlay {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
align-items: flex-start;
overflow: hidden;
padding: clamp(4px, 1.2vmin, 14px);
color: #eafffa;
background: transparent;
pointer-events: none;
}
.gift-menu-viewport {
position: relative;
width: 100%;
height: min(100%, var(--menu-panel-height));
min-height: min(100%, var(--menu-row-height));
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--menu-jade) 38%, transparent);
border-radius: clamp(10px, 1.5vmin, 22px);
outline: 1px solid rgba(255, 227, 142, 0.07);
outline-offset: -4px;
background: linear-gradient(145deg, rgba(3, 31, 44, 0.64), rgba(4, 54, 57, 0.4));
box-shadow:
inset 0 0 22px rgba(103, 240, 216, 0.07),
0 5px 18px rgba(0, 8, 18, 0.2);
scrollbar-width: none;
}
.gift-menu-preview {
pointer-events: auto;
}
.gift-menu-viewport::-webkit-scrollbar {
display: none;
}
.gift-menu-track {
position: relative;
z-index: 1;
width: 100%;
}
.gift-menu-row {
position: relative;
isolation: isolate;
display: grid;
width: 100%;
height: var(--menu-row-height);
min-width: 0;
grid-template-columns: calc(var(--menu-row-height) * 0.72) minmax(0, 1fr) auto;
align-items: center;
gap: calc(var(--menu-row-height) * 0.13);
overflow: hidden;
padding: calc(var(--menu-row-height) * 0.1) calc(var(--menu-row-height) * 0.22);
border: 1px solid color-mix(in srgb, var(--menu-jade) 36%, transparent);
border-radius: calc(var(--menu-row-height) * 0.17);
background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(7, 63, 66, 0.72) 58%, rgba(3, 27, 41, 0.9)),
var(--menu-vine) right center / auto 170% no-repeat;
box-shadow:
inset 0 0 calc(var(--menu-row-height) * 0.22) rgba(105, 239, 216, 0.08),
0 calc(var(--menu-row-height) * 0.05) calc(var(--menu-row-height) * 0.14) rgba(0, 8, 18, 0.34);
}
.gift-menu-row + .gift-menu-row {
margin-top: 0;
}
.gift-menu-row:nth-child(3n + 2) {
background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(13, 58, 69, 0.74), rgba(3, 27, 41, 0.9)),
var(--menu-divider) 76% 50% / auto 150% no-repeat;
}
.gift-menu-row:nth-child(3n) {
background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(11, 68, 64, 0.72), rgba(3, 27, 41, 0.9)),
var(--menu-cluster) right 10% center / auto 160% no-repeat;
}
.gift-menu-row::after {
content: '';
position: absolute;
inset: 4px;
z-index: -1;
border: 1px solid rgba(255, 226, 138, 0.1);
border-radius: inherit;
pointer-events: none;
}
.gift-menu-icon {
position: relative;
display: grid;
width: calc(var(--menu-row-height) * 0.58);
height: calc(var(--menu-row-height) * 0.58);
place-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--menu-gold) 44%, var(--menu-jade));
border-radius: 50%;
color: var(--menu-gold);
background: radial-gradient(
circle at 35% 28%,
rgba(255, 255, 255, 0.28),
rgba(11, 81, 78, 0.78) 45%,
rgba(3, 23, 38, 0.94)
);
box-shadow:
0 0 calc(var(--menu-row-height) * 0.12) rgba(103, 244, 218, 0.34),
inset 0 0 calc(var(--menu-row-height) * 0.08) rgba(255, 225, 140, 0.22);
font-size: calc(var(--menu-row-height) * 0.22);
font-style: normal;
flex: none;
}
.gift-menu-gift-icon i {
position: absolute;
}
.gift-menu-gift-icon img {
position: relative;
z-index: 1;
width: 82%;
height: 82%;
object-fit: contain;
filter: drop-shadow(0 0 6px rgba(202, 255, 244, 0.58));
}
.gift-menu-guard-icon {
border-radius: 34% 66% 36% 64%;
font-size: calc(var(--menu-row-height) * 0.23);
text-shadow: 0 0 7px currentColor;
}
.gift-menu-guard-icon img {
width: 90%;
height: 90%;
object-fit: contain;
filter: drop-shadow(0 0 7px currentColor);
}
.gift-menu-guard-icon.level-admiral {
color: var(--menu-rose);
}
.gift-menu-guard-icon.level-governor {
color: #fff0a8;
box-shadow: 0 0 calc(var(--menu-row-height) * 0.18) rgba(255, 215, 105, 0.5);
}
.gift-menu-battery-icon i {
position: relative;
width: 34%;
height: 58%;
border: 2px solid var(--menu-gold);
border-radius: 18%;
background: linear-gradient(to top, #ffd75c 0 68%, transparent 68%);
box-shadow: 0 0 8px rgba(255, 217, 93, 0.55);
}
.gift-menu-battery-icon i::before {
content: '';
position: absolute;
top: -13%;
left: 30%;
width: 40%;
height: 10%;
border-radius: 2px 2px 0 0;
background: var(--menu-gold);
}
.gift-menu-copy {
display: grid;
min-width: 0;
gap: calc(var(--menu-row-height) * 0.035);
line-height: 1.2;
}
.gift-menu-copy strong {
overflow: hidden;
color: var(--menu-gold);
font-size: var(--menu-title-size);
font-weight: 650;
letter-spacing: 0.05em;
text-overflow: ellipsis;
text-shadow: 0 0 8px rgba(255, 221, 123, 0.28);
white-space: nowrap;
}
.gift-menu-copy span {
display: -webkit-box;
overflow: hidden;
color: #edfffb;
font-size: var(--menu-copy-size);
line-height: 1.25;
text-shadow: 0 1px 4px rgba(0, 7, 15, 0.9);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.gift-menu-row em {
position: absolute;
inset: calc(var(--menu-row-height) * 0.06);
z-index: 7;
display: grid;
min-width: 0;
place-items: center;
overflow: visible;
padding: 0.28em 1em;
border: 1px solid rgba(255, 229, 146, 0.42);
border-radius: calc(var(--menu-row-height) * 0.13);
color: #fff5bf;
background:
linear-gradient(90deg, rgba(3, 28, 42, 0.96), rgba(12, 77, 72, 0.94), rgba(3, 28, 42, 0.96)),
var(--menu-divider) center / auto 180% no-repeat;
box-shadow:
inset 0 0 calc(var(--menu-row-height) * 0.22) rgba(109, 246, 219, 0.18),
0 0 calc(var(--menu-row-height) * 0.16) rgba(255, 221, 125, 0.16);
font-size: max(var(--menu-badge-size), calc(var(--menu-row-height) * 0.18));
font-weight: 700;
font-style: normal;
line-height: 1.15;
overflow-wrap: anywhere;
text-align: center;
text-shadow:
0 1px 5px rgba(0, 5, 12, 0.95),
0 0 8px rgba(255, 224, 139, 0.25);
white-space: normal;
}
.gift-menu-row.triggered {
z-index: 2;
border-color: rgba(255, 231, 143, 0.92);
animation: gift-menu-row-awaken var(--menu-highlight-duration) ease-out both;
}
.gift-menu-row.triggered::before {
content: '';
position: absolute;
inset: -40% -25%;
z-index: -1;
background: linear-gradient(
105deg,
transparent 27%,
rgba(255, 218, 107, 0.08) 39%,
rgba(255, 250, 211, 0.78) 50%,
rgba(105, 245, 219, 0.32) 58%,
transparent 72%
);
transform: translateX(-65%);
animation: gift-menu-highlight-sweep var(--menu-highlight-duration) ease-out both;
}
.gift-menu-row-particles {
position: absolute;
inset: 0;
z-index: 4;
overflow: hidden;
pointer-events: none;
}
.gift-menu-row-particles i {
position: absolute;
top: 55%;
width: calc(var(--menu-row-height) * 0.08);
height: calc(var(--menu-row-height) * 0.08);
opacity: 0;
background: linear-gradient(135deg, var(--menu-gold), var(--menu-cyan));
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
}
.gift-menu-row-particles i:nth-child(1) {
left: 9%;
animation-delay: 0ms;
}
.gift-menu-row-particles i:nth-child(2) {
left: 20%;
animation-delay: 55ms;
}
.gift-menu-row-particles i:nth-child(3) {
left: 31%;
animation-delay: 110ms;
}
.gift-menu-row-particles i:nth-child(4) {
left: 42%;
animation-delay: 165ms;
}
.gift-menu-row-particles i:nth-child(5) {
left: 53%;
animation-delay: 220ms;
}
.gift-menu-row-particles i:nth-child(6) {
left: 64%;
animation-delay: 275ms;
}
.gift-menu-row-particles i:nth-child(7) {
left: 75%;
animation-delay: 330ms;
}
.gift-menu-row-particles i:nth-child(8) {
left: 86%;
animation-delay: 385ms;
}
.gift-menu-row.triggered .gift-menu-row-particles i {
animation-name: gift-menu-particle-bloom;
animation-duration: var(--menu-particle-duration);
animation-timing-function: ease-out;
animation-fill-mode: both;
}
.gift-menu-empty {
display: grid;
min-height: var(--menu-row-height);
place-items: center;
border: 1px dashed rgba(111, 240, 216, 0.34);
border-radius: 16px;
color: rgba(220, 255, 248, 0.78);
background: rgba(3, 31, 44, 0.58);
font-size: var(--menu-empty-size);
}
.gift-menu-low-motion .gift-menu-row-particles,
.gift-menu-low-motion .gift-menu-row.triggered::before {
display: none;
}
@keyframes gift-menu-row-awaken {
0% {
filter: brightness(1);
transform: scale(1);
}
8% {
filter: brightness(1.65) saturate(1.2);
transform: scale(0.995);
}
28% {
filter: brightness(1.24);
transform: scale(1);
}
100% {
filter: brightness(1);
}
}
@keyframes gift-menu-highlight-sweep {
0% {
opacity: 0;
transform: translateX(-65%);
}
12% {
opacity: var(--menu-motion);
}
55% {
opacity: 0.72;
}
100% {
opacity: 0;
transform: translateX(65%);
}
}
@keyframes gift-menu-particle-bloom {
0% {
opacity: 0;
transform: translateY(10px) rotate(0) scale(0.4);
}
18% {
opacity: var(--menu-motion);
}
100% {
opacity: 0;
transform: translateY(calc(var(--menu-row-height) * -0.62)) rotate(100deg) scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.gift-menu-row-particles,
.gift-menu-row.triggered::before {
display: none;
}
}
+306
View File
@@ -0,0 +1,306 @@
/** Transparent, infinitely scrolling gift-to-content menu for OBS. */
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGiftMenuSettings } from './api'
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemeVariables } from './giftMenuThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftMenuSettings } from './types'
import type { GiftMenuItem, GiftMenuSettings } from './types'
type MenuEnvelope = {
id: string
type: string
payload?: {
settings?: Partial<GiftMenuSettings>
itemIds?: string[]
viewer?: { name?: string }
}
}
type ActiveTrigger = {
nonce: number
itemIds: string[]
viewer: string
}
function triggerLabel(item: GiftMenuItem): string {
if (item.trigger.kind === 'gift') return item.trigger.giftName
if (item.trigger.kind === 'guard') return translate(`gift_menu.guard.${item.trigger.level}`)
return translate('gift_menu.overlay.battery', {
amount: item.trigger.amount,
})
}
function MenuIcon({ item }: { item: GiftMenuItem }) {
if (item.trigger.kind === 'gift') {
return (
<span className="gift-menu-icon gift-menu-gift-icon">
<i aria-hidden="true">✦</i>
{item.trigger.imageUrl && (
<img
src={item.trigger.imageUrl}
alt=""
decoding="async"
referrerPolicy="no-referrer"
onError={event => {
event.currentTarget.hidden = true
}}
/>
)}
</span>
)
}
if (item.trigger.kind === 'guard') {
return (
<span className={`gift-menu-icon gift-menu-guard-icon level-${item.trigger.level}`}>
<img src={giftMenuGuardIconUrl(item.trigger.level)} alt="" decoding="async" />
</span>
)
}
return (
<span className="gift-menu-icon gift-menu-battery-icon" aria-hidden="true">
<i />
</span>
)
}
function GiftMenuRow({
item,
triggered,
viewer,
onPreviewTrigger,
}: {
item: GiftMenuItem
triggered: boolean
viewer: string
onPreviewTrigger?: (id: string) => void
}) {
return (
<article
className={`gift-menu-row ${triggered ? 'triggered' : ''}`}
data-item-id={item.id}
onClick={onPreviewTrigger ? () => onPreviewTrigger(item.id) : undefined}
role={onPreviewTrigger ? 'button' : undefined}
tabIndex={onPreviewTrigger ? 0 : undefined}
onKeyDown={event => {
if (onPreviewTrigger && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
onPreviewTrigger(item.id)
}
}}
>
<MenuIcon item={item} />
<div className="gift-menu-copy">
<strong>{triggerLabel(item)}</strong>
<span>{item.description}</span>
</div>
{triggered && viewer && <em>{translate('gift_menu.overlay.triggered_by', { viewer })}</em>}
<div className="gift-menu-row-particles" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</article>
)
}
function useGiftMenuStream(preview: boolean, stream?: ComponentStream) {
const [settings, setSettings] = useState(defaultGiftMenuSettings)
const [active, setActive] = useState<ActiveTrigger>()
const lastSequence = useRef(0)
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequence.current)
for (const message of pending) {
lastSequence.current = message.sequence
const envelope = message.envelope as MenuEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
setSettings(normalizeGiftMenuSettings(envelope.payload?.settings))
} else if (envelope.type === 'gift-menu.triggered') {
const itemIds = Array.isArray(envelope.payload?.itemIds)
? envelope.payload.itemIds.filter(id => typeof id === 'string')
: []
if (itemIds.length)
setActive({
nonce: Date.now(),
itemIds,
viewer: String(envelope.payload?.viewer?.name ?? ''),
})
}
}
}, [preview, stream, stream?.messages])
return { settings, active, setActive }
}
export function GiftMenuOverlay({
preview = false,
previewSettings,
previewTriggeredItemId,
previewNonce = 0,
onPreviewTrigger,
stream,
}: {
preview?: boolean
previewSettings?: GiftMenuSettings
previewTriggeredItemId?: string
previewNonce?: number
onPreviewTrigger?: (id: string) => void
stream?: ComponentStream
}) {
useI18n()
const remote = useGiftMenuStream(preview, stream)
const settings = previewSettings ?? remote.settings
const [active, setActive] = useState<ActiveTrigger>()
const effectiveActive = preview ? active : remote.active
const viewport = useRef<HTMLDivElement>(null)
const pauseUntil = useRef(0)
// Older OBS Chromium builds quantize scrollTop to whole pixels. Retaining
// the sub-pixel remainder makes every configured speed linear instead of
// losing movements smaller than one pixel per animation frame.
const scrollRemainder = useRef(0)
const [loop, setLoop] = useState(false)
const theme = getGiftMenuTheme(settings.themeId)
const cycleHeight = settings.items.length * settings.rowHeight
useEffect(() => {
if (!preview || !previewTriggeredItemId) return
setActive({
nonce: previewNonce,
itemIds: [previewTriggeredItemId],
viewer: '',
})
}, [preview, previewNonce, previewTriggeredItemId])
useEffect(() => {
const node = viewport.current
if (!node) return
const update = () => setLoop(cycleHeight > node.clientHeight + 1)
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [cycleHeight])
useEffect(() => {
const node = viewport.current
if (!node || !loop || cycleHeight <= 0) return
if (node.scrollTop < cycleHeight || node.scrollTop >= cycleHeight * 2)
node.scrollTop = cycleHeight + (node.scrollTop % cycleHeight)
scrollRemainder.current = 0
let frame = 0
let previous = performance.now()
const animate = (now: number) => {
const elapsed = Math.min(50, now - previous)
previous = now
if (now >= pauseUntil.current && settings.scrollSpeedPixelsPerSecond > 0) {
scrollRemainder.current += (settings.scrollSpeedPixelsPerSecond * elapsed) / 1_000
const wholePixels = Math.floor(scrollRemainder.current)
if (wholePixels > 0) {
node.scrollTop += wholePixels
scrollRemainder.current -= wholePixels
}
if (node.scrollTop >= cycleHeight * 2) node.scrollTop -= cycleHeight
if (node.scrollTop < cycleHeight) node.scrollTop += cycleHeight
}
frame = requestAnimationFrame(animate)
}
frame = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(frame)
scrollRemainder.current = 0
}
}, [cycleHeight, loop, settings.scrollSpeedPixelsPerSecond])
useEffect(() => {
const node = viewport.current
const id = effectiveActive?.itemIds[0]
if (!node || !id) return
const index = settings.items.findIndex(item => item.id === id)
if (index < 0) return
scrollRemainder.current = 0
if (!loop || cycleHeight <= 0) {
node.scrollTo({
top: index * settings.rowHeight,
behavior: 'smooth',
})
} else {
const alignedToTop = (copy: number) => copy * cycleHeight + index * settings.rowHeight
const candidates = [0, 1, 2].map(alignedToTop)
const target = candidates.reduce((best, value) =>
Math.abs(value - node.scrollTop) < Math.abs(best - node.scrollTop) ? value : best,
)
node.scrollTo({ top: Math.max(0, target), behavior: 'smooth' })
}
pauseUntil.current = performance.now() + settings.highlightDurationMs
}, [
cycleHeight,
effectiveActive?.nonce,
loop,
settings.highlightDurationMs,
settings.items,
settings.rowHeight,
])
useEffect(() => {
if (!effectiveActive) return
const timer = window.setTimeout(() => {
if (preview) setActive(undefined)
else remote.setActive(undefined)
}, settings.highlightDurationMs)
return () => window.clearTimeout(timer)
}, [effectiveActive?.nonce, preview, settings.highlightDurationMs])
const copies = useMemo(() => (loop ? [0, 1, 2] : [0]), [loop])
return (
<main
className={`gift-menu-overlay ${theme.className} ${preview ? 'gift-menu-preview' : ''} ${settings.lowPerformanceMode ? 'gift-menu-low-motion' : ''}`}
style={
{
...giftMenuThemeVariables(theme),
['--menu-row-height' as string]: `${settings.rowHeight}px`,
['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`,
['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`,
['--menu-copy-size' as string]: `${settings.rowHeight * 0.17 * (settings.fontScale / 100)}px`,
['--menu-badge-size' as string]: `${settings.rowHeight * 0.13 * (settings.fontScale / 100)}px`,
['--menu-empty-size' as string]: `${18 * (settings.fontScale / 100)}px`,
['--menu-highlight-duration' as string]: `${settings.highlightDurationMs}ms`,
['--menu-particle-duration' as string]: `${settings.highlightDurationMs * 0.72}ms`,
['--menu-motion' as string]: settings.motionIntensity / 100,
} as CSSProperties
}
data-connection={stream?.connection ?? 'idle'}
>
<section
className="gift-menu-viewport"
ref={viewport}
aria-label={translate('gift_menu.aria')}
>
{settings.items.length === 0 ? (
<div className="gift-menu-empty">{translate('gift_menu.overlay.empty')}</div>
) : (
<div className="gift-menu-track">
{copies.flatMap(copy =>
settings.items.map(item => (
<GiftMenuRow
item={item}
triggered={effectiveActive?.itemIds.includes(item.id) === true}
viewer={effectiveActive?.viewer ?? ''}
onPreviewTrigger={onPreviewTrigger}
key={`${copy}:${item.id}`}
/>
)),
)}
</div>
)}
</section>
</main>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { CSSProperties } from 'react'
import type { GiftMenuThemeId } from './types'
export type GiftMenuTheme = {
id: GiftMenuThemeId
nameKey: string
descriptionKey: string
className: string
palette: {
jade: string
cyan: string
gold: string
rose: string
ink: string
}
ornaments: { divider: string; cluster: string; vine: string }
}
export const giftMenuThemes: readonly GiftMenuTheme[] = [
{
id: 'jade-banquet',
nameKey: 'gift_menu.theme.jade_banquet.name',
descriptionKey: 'gift_menu.theme.jade_banquet.description',
className: 'gift-menu-theme-jade-banquet',
palette: {
jade: '#71f0d5',
cyan: '#b8fff4',
gold: '#ffe28a',
rose: '#ffcce1',
ink: '#031a26',
},
ornaments: {
divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.svg',
},
},
]
export function getGiftMenuTheme(id: unknown): GiftMenuTheme {
return giftMenuThemes.find(theme => theme.id === id) ?? giftMenuThemes[0]
}
export function normalizeGiftMenuThemeId(id: unknown): GiftMenuThemeId {
return getGiftMenuTheme(id).id
}
/** Local transparent membership icons supplied with the overlay bundle. */
export function giftMenuGuardIconUrl(level: 'captain' | 'admiral' | 'governor'): string {
return `/assets/${level}.png`
}
export function giftMenuThemeVariables(theme: GiftMenuTheme): CSSProperties {
return {
['--menu-jade' as string]: theme.palette.jade,
['--menu-cyan' as string]: theme.palette.cyan,
['--menu-gold' as string]: theme.palette.gold,
['--menu-rose' as string]: theme.palette.rose,
['--menu-ink' as string]: theme.palette.ink,
['--menu-divider' as string]: `url(${theme.ornaments.divider})`,
['--menu-cluster' as string]: `url(${theme.ornaments.cluster})`,
['--menu-vine' as string]: `url(${theme.ornaments.vine})`,
}
}
+3
View File
@@ -18,6 +18,7 @@ import {
SongRequestsPage,
} from './control'
import { GiftEffectOverlay } from './giftEffect'
import { GiftMenuOverlay } from './giftMenu'
import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
import { SongRequestOverlay } from './songOverlay'
@@ -27,6 +28,7 @@ import type { Session } from './types'
import './style.css'
import './control.css'
import './giftEffect.css'
import './giftMenu.css'
import './song.css'
function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) {
@@ -41,6 +43,7 @@ function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken
return <main className="obs-status">{t('main.obs_invalid_token')}</main>
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
if (stream.componentKind === 'gift_effect') return <GiftEffectOverlay stream={stream} />
if (stream.componentKind === 'gift_menu') return <GiftMenuOverlay 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>
+55 -1
View File
@@ -108,7 +108,61 @@ export const defaultGiftEffectSettings: GiftEffectSettings = {
lowPerformanceMode: false,
}
export type ComponentSettings = OverlaySettings | SongRequestSettings | GiftEffectSettings
export type GiftCatalogItem = {
id: number
name: string
coinType: string
batteryValue: number
unitPrice: number
imageUrl?: string
animationUrl?: string
}
export type GiftMenuTrigger =
| {
kind: 'gift'
giftId: number
giftName: string
imageUrl?: string
unitPrice: number
}
| { kind: 'guard'; level: 'captain' | 'admiral' | 'governor' }
| { kind: 'battery'; amount: number }
export type GiftMenuItem = {
id: string
trigger: GiftMenuTrigger
description: string
}
export type GiftMenuThemeId = 'jade-banquet'
export type GiftMenuSettings = {
themeId: GiftMenuThemeId
items: GiftMenuItem[]
visibleRows: number
rowHeight: number
scrollSpeedPixelsPerSecond: number
highlightDurationMs: number
fontScale: number
motionIntensity: number
lowPerformanceMode: boolean
}
export const defaultGiftMenuSettings: GiftMenuSettings = {
themeId: 'jade-banquet',
items: [],
visibleRows: 4,
rowHeight: 88,
scrollSpeedPixelsPerSecond: 28,
highlightDurationMs: 3_800,
fontScale: 100,
motionIntensity: 78,
lowPerformanceMode: false,
}
export type ComponentSettings =
OverlaySettings | SongRequestSettings | GiftEffectSettings | GiftMenuSettings
export type SongRequester = { uid: string; name: string }