diff --git a/README.md b/README.md
index f69b131..6873e78 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# 洛星瓷直播组件服务
-这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬与
-`gift_effect`
-全屏礼物特效,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
+这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬、
+`gift_effect` 全屏礼物特效与 `gift_menu`
+礼物菜单,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
crate。它负责 Cookie/WBI
@@ -21,6 +21,7 @@ adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。cr
- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md)
- [`song_request` 点歌姬](docs/components/song-request.md)
- [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md)
+- [`gift_menu` 礼物菜单](docs/components/gift-menu.md)
- [WebSocket 实时协议](docs/protocol.md)
- [租户、Secret 与部署安全](docs/security.md)
- [完整配置注释](config.toml.example)
@@ -210,12 +211,15 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通
`/api/v1/components//stream` 完成认证。令牌只带 `events:subscribe`
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
-每个账户会自动拥有不可删除的点歌姬和全屏礼物特效组件。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5`
+每个账户会自动拥有不可删除的点歌姬、全屏礼物特效和礼物菜单组件。观众发送 `点歌 歌名` 加入队列,发送
+`打分 1-5`
为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度;舰长、提督和总督事件会临时覆盖一层不透明星河庆祝画面。所有档位参数、拖尾、星数、持续时间和低性能模式均可在控制台调整。该组件只订阅一次性
`live.gift` 与 `live.guard.buy`,不会把连击更新重复播放为新礼物。
+礼物菜单读取账户当前直播间的礼物目录与图标,可把指定礼物、舰长/提督/总督或指定礼物单价映射为自定义直播内容。OBS 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
+
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer
token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
diff --git a/apps/overlay/README.md b/apps/overlay/README.md
index 56534c6..31e5deb 100644
--- a/apps/overlay/README.md
+++ b/apps/overlay/README.md
@@ -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)
diff --git a/apps/overlay/public/assets/admiral.png b/apps/overlay/public/assets/admiral.png
new file mode 100644
index 0000000..5025e7b
Binary files /dev/null and b/apps/overlay/public/assets/admiral.png differ
diff --git a/apps/overlay/public/assets/captain.png b/apps/overlay/public/assets/captain.png
new file mode 100644
index 0000000..f3fac9b
Binary files /dev/null and b/apps/overlay/public/assets/captain.png differ
diff --git a/apps/overlay/public/assets/governor.png b/apps/overlay/public/assets/governor.png
new file mode 100644
index 0000000..b77d4bf
Binary files /dev/null and b/apps/overlay/public/assets/governor.png differ
diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts
index 2963efa..2b5f0ee 100644
--- a/apps/overlay/src/api.ts
+++ b/apps/overlay/src/api.ts
@@ -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),
+ 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)
diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css
index f9719d6..69262d8 100644
--- a/apps/overlay/src/control.css
+++ b/apps/overlay/src/control.css
@@ -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,
diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx
index c9de582..e5d726b 100644
--- a/apps/overlay/src/control.tsx
+++ b/apps/overlay/src/control.tsx
@@ -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
+ saving: boolean
+}) {
+ const [catalog, setCatalog] = useState([])
+ 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 = (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(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 (
+
+
+
+
+ {translate('gift_menu.catalog.title')}
+
+ {catalogBusy
+ ? translate('gift_menu.catalog.loading')
+ : translate('gift_menu.catalog.count', { count: catalog.length })}
+
+
+
+
+ {catalogError &&
{catalogError}
}
+
+
+ {draftError &&
{draftError}
}
+
+
+ {settings.items.length === 0 ? (
+
{translate('gift_menu.editor.empty')}
+ ) : (
+ settings.items.map((item, index) => (
+
+
{index + 1}
+ {item.trigger.kind === 'gift' && item.trigger.imageUrl ? (
+

+ ) : item.trigger.kind === 'guard' ? (
+
})
+ ) : (
+
+ {item.trigger.kind === 'battery'
+ ? translate('gift_menu.battery_mark')
+ : translate('components.gift_mark')}
+
+ )}
+
+
+ {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,
+ })}
+
+ {item.description}
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function GiftMenuPreview({ settings }: { settings: GiftMenuSettings }) {
+ const [triggered, setTriggered] = useState()
+ const [nonce, setNonce] = useState(0)
+ const trigger = (id: string) => {
+ setTriggered(id)
+ setNonce(current => current + 1)
+ }
+ return (
+
+
+
+
+
+ )
+}
+
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)}
/>
+
{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}
>
+ ) : isGiftMenuKind(selected.kind) && settings ? (
+ <>
+
+ setSettings(next)}
+ onSave={saveSettings}
+ saving={saving}
+ />
+
+
+ >
) : (
{translate('components.no_editor')}
)}
- {(isDanmakuKind(selected.kind) || isGiftEffectKind(selected.kind)) && (
+ {(isDanmakuKind(selected.kind) ||
+ isGiftEffectKind(selected.kind) ||
+ isGiftMenuKind(selected.kind)) && (
)}
@@ -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({
)}
@@ -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')),
+ })
}
}
diff --git a/apps/overlay/src/giftMenu.css b/apps/overlay/src/giftMenu.css
new file mode 100644
index 0000000..a4f3011
--- /dev/null
+++ b/apps/overlay/src/giftMenu.css
@@ -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;
+ }
+}
diff --git a/apps/overlay/src/giftMenu.tsx b/apps/overlay/src/giftMenu.tsx
new file mode 100644
index 0000000..3261b63
--- /dev/null
+++ b/apps/overlay/src/giftMenu.tsx
@@ -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
+ 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 (
+
+ ✦
+ {item.trigger.imageUrl && (
+
{
+ event.currentTarget.hidden = true
+ }}
+ />
+ )}
+
+ )
+ }
+ if (item.trigger.kind === 'guard') {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+}
+
+function GiftMenuRow({
+ item,
+ triggered,
+ viewer,
+ onPreviewTrigger,
+}: {
+ item: GiftMenuItem
+ triggered: boolean
+ viewer: string
+ onPreviewTrigger?: (id: string) => void
+}) {
+ return (
+ 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)
+ }
+ }}
+ >
+
+
+ {triggerLabel(item)}
+ {item.description}
+
+ {triggered && viewer && {translate('gift_menu.overlay.triggered_by', { viewer })}}
+
+ {Array.from({ length: 8 }, (_, index) => (
+
+ ))}
+
+
+ )
+}
+
+function useGiftMenuStream(preview: boolean, stream?: ComponentStream) {
+ const [settings, setSettings] = useState(defaultGiftMenuSettings)
+ const [active, setActive] = useState()
+ 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()
+ const effectiveActive = preview ? active : remote.active
+ const viewport = useRef(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 (
+
+
+ {settings.items.length === 0 ? (
+ {translate('gift_menu.overlay.empty')}
+ ) : (
+
+ {copies.flatMap(copy =>
+ settings.items.map(item => (
+
+ )),
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/apps/overlay/src/giftMenuThemes.ts b/apps/overlay/src/giftMenuThemes.ts
new file mode 100644
index 0000000..051ab40
--- /dev/null
+++ b/apps/overlay/src/giftMenuThemes.ts
@@ -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})`,
+ }
+}
diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx
index d5ee459..dbe4803 100644
--- a/apps/overlay/src/main.tsx
+++ b/apps/overlay/src/main.tsx
@@ -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 {t('main.obs_invalid_token')}
if (stream.componentKind === 'song_request') return
if (stream.componentKind === 'gift_effect') return
+ if (stream.componentKind === 'gift_menu') return
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
return
return {t('main.obs_connecting')}
diff --git a/apps/overlay/src/types.ts b/apps/overlay/src/types.ts
index a4f3390..57d740f 100644
--- a/apps/overlay/src/types.ts
+++ b/apps/overlay/src/types.ts
@@ -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 }
diff --git a/apps/server-rust/README.md b/apps/server-rust/README.md
index 79e38b5..a5dd3ef 100644
--- a/apps/server-rust/README.md
+++ b/apps/server-rust/README.md
@@ -23,6 +23,7 @@ crate 导出。
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
+| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
## 重要不变量
diff --git a/apps/server-rust/migrations/010_gift_menu.sql b/apps/server-rust/migrations/010_gift_menu.sql
new file mode 100644
index 0000000..46a671c
--- /dev/null
+++ b/apps/server-rust/migrations/010_gift_menu.sql
@@ -0,0 +1,6 @@
+-- Every account owns exactly one built-in gift-menu component. Its ordered
+-- entries remain in validated component settings and inherit component RLS.
+
+CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_gift_menu
+ ON component_instances(owner_user_id, kind)
+ WHERE kind = 'gift_menu';
diff --git a/apps/server-rust/src/app.rs b/apps/server-rust/src/app.rs
index b7a875c..bb46834 100644
--- a/apps/server-rust/src/app.rs
+++ b/apps/server-rust/src/app.rs
@@ -24,6 +24,7 @@ use crate::{
bilibili::BilibiliProvider,
supervisor::{ProviderFactory, SourceSupervisor},
},
+ overlay::GiftCatalogRegistry,
rate_limit::AuthRateLimiter,
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
repository::TenantRepository,
@@ -46,6 +47,7 @@ pub struct AppState {
pub component_socket_slots: Arc,
pub http: reqwest::Client,
pub song_requests: SongRequestService,
+ pub gift_catalogs: GiftCatalogRegistry,
}
impl AppState {
@@ -65,6 +67,7 @@ impl AppState {
let component_cache = Arc::new(InMemoryComponentStore::default());
let hub = EventHub::new(512);
let song_requests = SongRequestService::new(db.clone(), hub.clone());
+ let gift_catalogs = GiftCatalogRegistry::default();
registry
.register_handler(
SONG_REQUEST_KIND,
@@ -98,6 +101,7 @@ impl AppState {
auth: auth.clone(),
config: config.clone(),
http: http.clone(),
+ gift_catalogs: gift_catalogs.clone(),
});
let (source_events, mut source_event_rx) = mpsc::channel::>(512);
let supervisor = SourceSupervisor::new(provider_factory, source_events);
@@ -135,6 +139,7 @@ impl AppState {
component_socket_slots: Arc::new(Semaphore::new(128)),
http,
song_requests,
+ gift_catalogs,
};
state.start_all_sources().await?;
Ok(state)
@@ -229,6 +234,7 @@ struct BilibiliProviderFactory {
auth: AuthService,
config: Arc,
http: reqwest::Client,
+ gift_catalogs: GiftCatalogRegistry,
}
#[async_trait]
@@ -242,12 +248,14 @@ impl ProviderFactory for BilibiliProviderFactory {
.ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?;
self.config.allowed_cookiecloud_host(&stored.host)?;
let cookie = fetch_bilibili_cookie(&self.http, &stored).await?;
- Ok(Arc::new(BilibiliProvider::new(
+ let gift_catalog = self.gift_catalogs.catalog(source.owner_id).await;
+ Ok(Arc::new(BilibiliProvider::with_gift_catalog(
cookie,
self.config.gift_refresh_seconds,
self.config.gift_request_timeout_seconds,
self.config.emoticon_refresh_seconds,
self.config.emoticon_request_timeout_seconds,
+ gift_catalog,
)))
}
}
@@ -292,6 +300,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
include_str!("../migrations/008_account_language.sql"),
),
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
+ (10_i32, include_str!("../migrations/010_gift_menu.sql")),
] {
let applied = transaction
.query_one(
diff --git a/apps/server-rust/src/auth.rs b/apps/server-rust/src/auth.rs
index f18e442..60bf4ab 100644
--- a/apps/server-rust/src/auth.rs
+++ b/apps/server-rust/src/auth.rs
@@ -27,6 +27,7 @@ use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
db::{Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
+ gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
i18n,
overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -668,6 +669,23 @@ impl AuthService {
],
)
.await?;
+ let menu_component_id = Uuid::new_v4();
+ let menu_settings = serde_json::to_value(GiftMenuSettings::default())
+ .expect("GiftMenuSettings 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)",
+ &[
+ &menu_component_id,
+ &user_id,
+ &GIFT_MENU_KIND,
+ &GIFT_MENU_NAME,
+ &menu_settings,
+ ],
+ )
+ .await?;
transaction
.execute(
"DELETE FROM pending_registrations WHERE id=$1",
diff --git a/apps/server-rust/src/components.rs b/apps/server-rust/src/components.rs
index facffc9..b98d8f5 100644
--- a/apps/server-rust/src/components.rs
+++ b/apps/server-rust/src/components.rs
@@ -21,6 +21,7 @@ use uuid::Uuid;
use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind},
gift_effect::GiftEffectDefinition,
+ gift_menu::{GiftMenuDefinition, GiftMenuProjection},
overlay::OverlaySettings,
song_request::{SongRequestDefinition, SongRequestProjection},
};
@@ -389,6 +390,9 @@ impl ComponentRegistry {
)
.expect("built-in component kinds are unique");
registry
+ .register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
+ .expect("built-in component kinds are unique");
+ registry
}
pub fn register(
@@ -574,6 +578,24 @@ mod tests {
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
+ #[test]
+ fn builtin_gift_menu_is_registered_as_a_gift_and_guard_projection() {
+ let registry = ComponentRegistry::default();
+ let runtime = registry.runtime("gift_menu").unwrap();
+ let instance = ComponentInstance::new(
+ Uuid::new_v4(),
+ Uuid::new_v4(),
+ "gift_menu",
+ "礼物菜单",
+ 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();
diff --git a/apps/server-rust/src/domain.rs b/apps/server-rust/src/domain.rs
index c3caf19..9503261 100644
--- a/apps/server-rust/src/domain.rs
+++ b/apps/server-rust/src/domain.rs
@@ -95,6 +95,9 @@ pub struct GiftDetails {
pub id: Option,
pub name: String,
pub coin_type: String,
+ /// Display value in Bilibili batteries. Upstream gift prices are gold
+ /// coin values, where 100 gold coins equal one battery.
+ pub battery_value: i64,
pub unit_price: i64,
pub total_price: i64,
pub price_cny: f64,
diff --git a/apps/server-rust/src/gift_menu.rs b/apps/server-rust/src/gift_menu.rs
new file mode 100644
index 0000000..629339d
--- /dev/null
+++ b/apps/server-rust/src/gift_menu.rs
@@ -0,0 +1,510 @@
+//! Configurable gift-to-content menu component.
+//!
+//! Menu entries live in the component's versioned JSON settings because they
+//! are a small, ordered presentation configuration rather than an event log.
+//! The projection is authoritative for trigger matching and emits only matched
+//! item IDs; the browser never has to reinterpret Bilibili guard names.
+
+use std::collections::HashSet;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use uuid::Uuid;
+
+use crate::{
+ components::{
+ ComponentDefinition, ComponentError, ComponentInstance, EventProjection, EventSubscription,
+ },
+ domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload},
+ overlay::normalize_image_url,
+};
+
+pub const GIFT_MENU_KIND: &str = "gift_menu";
+pub const GIFT_MENU_NAME: &str = "礼物菜单";
+const MAX_MENU_ITEMS: usize = 100;
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum GiftMenuThemeId {
+ #[default]
+ JadeBanquet,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum GuardLevel {
+ Captain,
+ Admiral,
+ Governor,
+}
+
+#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
+#[serde(
+ tag = "kind",
+ rename_all = "lowercase",
+ rename_all_fields = "camelCase"
+)]
+pub enum GiftMenuTrigger {
+ Gift {
+ gift_id: i64,
+ gift_name: String,
+ image_url: Option,
+ unit_price: i64,
+ },
+ Guard {
+ level: GuardLevel,
+ },
+ Battery {
+ amount: i64,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GiftMenuItem {
+ pub id: Uuid,
+ pub trigger: GiftMenuTrigger,
+ pub description: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GiftMenuSettings {
+ #[serde(default)]
+ pub theme_id: GiftMenuThemeId,
+ #[serde(default)]
+ pub items: Vec,
+ pub visible_rows: u8,
+ pub row_height: u16,
+ pub scroll_speed_pixels_per_second: u16,
+ pub highlight_duration_ms: u16,
+ pub font_scale: u16,
+ pub motion_intensity: u8,
+ pub low_performance_mode: bool,
+}
+
+impl Default for GiftMenuSettings {
+ fn default() -> Self {
+ Self {
+ theme_id: GiftMenuThemeId::default(),
+ items: Vec::new(),
+ visible_rows: 4,
+ row_height: 88,
+ scroll_speed_pixels_per_second: 28,
+ highlight_duration_ms: 3_800,
+ font_scale: 100,
+ motion_intensity: 78,
+ low_performance_mode: false,
+ }
+ }
+}
+
+impl GiftMenuSettings {
+ fn sanitize(mut self) -> Result {
+ if self.items.len() > MAX_MENU_ITEMS {
+ return Err(format!("gift menu accepts at most {MAX_MENU_ITEMS} items"));
+ }
+ let mut ids = HashSet::with_capacity(self.items.len());
+ let mut triggers = HashSet::with_capacity(self.items.len());
+ for item in &mut self.items {
+ if item.id.is_nil() || !ids.insert(item.id) {
+ return Err("gift menu item IDs must be unique and non-zero".into());
+ }
+ item.description = normalize_text(&item.description, 200)?;
+ let trigger_key = sanitize_trigger(&mut item.trigger)?;
+ if !triggers.insert(trigger_key) {
+ return Err("gift menu triggers must be unique".into());
+ }
+ }
+ self.visible_rows = self.visible_rows.clamp(1, 20);
+ self.row_height = self.row_height.clamp(44, 240);
+ self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240);
+ self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
+ self.font_scale = self.font_scale.clamp(50, 220);
+ self.motion_intensity = self.motion_intensity.min(100);
+ Ok(self)
+ }
+}
+
+fn normalize_text(value: &str, max_chars: usize) -> Result {
+ let normalized = value.split_whitespace().collect::>().join(" ");
+ let count = normalized.chars().count();
+ if count == 0 || count > max_chars {
+ return Err(format!("text must contain 1-{max_chars} characters"));
+ }
+ Ok(normalized)
+}
+
+fn sanitize_trigger(trigger: &mut GiftMenuTrigger) -> Result {
+ match trigger {
+ GiftMenuTrigger::Gift {
+ gift_id,
+ gift_name,
+ image_url,
+ unit_price,
+ } => {
+ if *gift_id <= 0 {
+ return Err("gift ID must be positive".into());
+ }
+ *gift_name = normalize_text(gift_name, 80)?;
+ *unit_price = (*unit_price).clamp(0, 1_000_000_000);
+ *image_url = image_url.as_deref().and_then(normalize_image_url);
+ Ok(format!("gift:{gift_id}"))
+ }
+ GiftMenuTrigger::Guard { level } => Ok(format!("guard:{level:?}")),
+ GiftMenuTrigger::Battery { amount } => {
+ if *amount <= 0 || *amount > 1_000_000_000 {
+ return Err("battery amount must be between 1 and 1000000000".into());
+ }
+ Ok(format!("battery:{amount}"))
+ }
+ }
+}
+
+pub struct GiftMenuDefinition;
+
+impl GiftMenuDefinition {
+ fn parse(&self, settings: Value) -> Result {
+ serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
+ kind: GIFT_MENU_KIND.to_owned(),
+ detail: error.to_string(),
+ })
+ }
+}
+
+impl ComponentDefinition for GiftMenuDefinition {
+ fn kind(&self) -> &'static str {
+ GIFT_MENU_KIND
+ }
+
+ fn settings_version(&self) -> u32 {
+ 1
+ }
+
+ fn default_settings(&self) -> Value {
+ serde_json::to_value(GiftMenuSettings::default())
+ .expect("GiftMenuSettings is always JSON serializable")
+ }
+
+ fn validate_settings(&self, settings: Value) -> Result {
+ let settings =
+ self.parse(settings)?
+ .sanitize()
+ .map_err(|detail| ComponentError::InvalidSettings {
+ kind: GIFT_MENU_KIND.to_owned(),
+ detail,
+ })?;
+ serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings {
+ kind: GIFT_MENU_KIND.to_owned(),
+ detail: error.to_string(),
+ })
+ }
+
+ fn subscriptions(&self, settings: &Value) -> Result {
+ self.parse(settings.clone())?;
+ Ok(EventSubscription::new([
+ LiveEventKind::Gift,
+ LiveEventKind::GuardPurchase,
+ ]))
+ }
+}
+
+#[derive(Default)]
+pub struct GiftMenuProjection;
+
+impl EventProjection for GiftMenuProjection {
+ fn project(
+ &self,
+ component: &ComponentInstance,
+ event: &LiveEvent,
+ ) -> Result