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}
} + +
+ {translate('gift_menu.editor.add_title')} + + {triggerKind === 'gift' && ( + + )} + {triggerKind === 'guard' && ( + + )} + {triggerKind === 'battery' && ( + + )} + + +
+ {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)} /> +