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:
@@ -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/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
|
||||
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
|
||||
|
||||
每个账户会自动拥有不可删除的点歌姬和全屏礼物特效组件。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5`
|
||||
每个账户会自动拥有不可删除的点歌姬、全屏礼物特效和礼物菜单组件。观众发送 `点歌 歌名` 加入队列,发送
|
||||
`打分 1-5`
|
||||
为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
|
||||
|
||||
礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度;舰长、提督和总督事件会临时覆盖一层不透明星河庆祝画面。所有档位参数、拖尾、星数、持续时间和低性能模式均可在控制台调整。该组件只订阅一次性
|
||||
`live.gift` 与 `live.guard.buy`,不会把连击更新重复播放为新礼物。
|
||||
|
||||
礼物菜单读取账户当前直播间的礼物目录与图标,可把指定礼物、舰长/提督/总督或指定礼物单价映射为自定义直播内容。OBS 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
|
||||
|
||||
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer
|
||||
token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
|
||||
|
||||
|
||||
+17
-14
@@ -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 |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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')),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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})`,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ crate 导出。
|
||||
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
||||
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
|
||||
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
|
||||
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
|
||||
|
||||
## 重要不变量
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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<Semaphore>,
|
||||
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::<Arc<LiveEvent>>(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<Config>,
|
||||
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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -95,6 +95,9 @@ pub struct GiftDetails {
|
||||
pub id: Option<i64>,
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
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<GiftMenuItem>,
|
||||
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<Self, String> {
|
||||
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<String, String> {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().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<String, String> {
|
||||
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<GiftMenuSettings, ComponentError> {
|
||||
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<Value, ComponentError> {
|
||||
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<EventSubscription, ComponentError> {
|
||||
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<Option<ComponentMessage>, ComponentError> {
|
||||
let settings: GiftMenuSettings = serde_json::from_value(component.settings.clone())
|
||||
.map_err(|error| ComponentError::Projection(error.to_string()))?;
|
||||
let (matched, viewer) = match &event.payload {
|
||||
LiveEventPayload::Gift(gift) => {
|
||||
// Concrete gift identities take precedence over price tiers.
|
||||
// Only fall back to battery matching when no configured gift
|
||||
// ID (or name, when the event has no ID) matched.
|
||||
let concrete = settings
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| match &item.trigger {
|
||||
GiftMenuTrigger::Gift {
|
||||
gift_id, gift_name, ..
|
||||
} => gift
|
||||
.gift
|
||||
.id
|
||||
.map_or(gift.gift.name == *gift_name, |id| id == *gift_id),
|
||||
_ => false,
|
||||
})
|
||||
.map(|item| item.id)
|
||||
.collect::<Vec<_>>();
|
||||
let matched = if concrete.is_empty() {
|
||||
settings
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
&item.trigger,
|
||||
GiftMenuTrigger::Battery { amount }
|
||||
if gift.gift.battery_value == *amount
|
||||
)
|
||||
})
|
||||
.map(|item| item.id)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
concrete
|
||||
};
|
||||
(matched, &gift.viewer)
|
||||
}
|
||||
LiveEventPayload::GuardPurchase(guard) => {
|
||||
let Some(level) = guard_level(&guard.guard_name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
settings
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(&item.trigger, GiftMenuTrigger::Guard { level: candidate } if *candidate == level)
|
||||
})
|
||||
.map(|item| item.id)
|
||||
.collect::<Vec<_>>(),
|
||||
&guard.viewer,
|
||||
)
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
if matched.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut message = ComponentMessage::from_live_event(component.id, event)
|
||||
.map_err(|error| ComponentError::Projection(error.to_string()))?;
|
||||
message.event_type = "gift-menu.triggered".into();
|
||||
message.payload = json!({
|
||||
"itemIds": matched,
|
||||
"viewer": viewer,
|
||||
"sourceEventId": event.id,
|
||||
});
|
||||
Ok(Some(message))
|
||||
}
|
||||
}
|
||||
|
||||
fn guard_level(name: &str) -> Option<GuardLevel> {
|
||||
let name = name.trim().to_lowercase();
|
||||
if name.contains("总督") || name.contains("governor") {
|
||||
Some(GuardLevel::Governor)
|
||||
} else if name.contains("提督") || name.contains("admiral") {
|
||||
Some(GuardLevel::Admiral)
|
||||
} else if name.contains("舰长") || name.contains("captain") {
|
||||
Some(GuardLevel::Captain)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{
|
||||
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEventPayload, PlatformViewer,
|
||||
};
|
||||
|
||||
fn viewer() -> PlatformViewer {
|
||||
PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn component(items: Vec<GiftMenuItem>) -> ComponentInstance {
|
||||
let settings = GiftMenuSettings {
|
||||
items,
|
||||
..GiftMenuSettings::default()
|
||||
};
|
||||
ComponentInstance::new(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
GIFT_MENU_KIND,
|
||||
GIFT_MENU_NAME,
|
||||
1,
|
||||
serde_json::to_value(settings).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_reject_duplicate_triggers_and_bound_renderer_values() {
|
||||
let trigger = GiftMenuTrigger::Battery { amount: 150 };
|
||||
let items = vec![
|
||||
GiftMenuItem {
|
||||
id: Uuid::new_v4(),
|
||||
trigger: trigger.clone(),
|
||||
description: "点歌".into(),
|
||||
},
|
||||
GiftMenuItem {
|
||||
id: Uuid::new_v4(),
|
||||
trigger,
|
||||
description: "学歌".into(),
|
||||
},
|
||||
];
|
||||
let definition = GiftMenuDefinition;
|
||||
let settings = GiftMenuSettings {
|
||||
items,
|
||||
..GiftMenuSettings::default()
|
||||
};
|
||||
assert!(
|
||||
definition
|
||||
.validate_settings(serde_json::to_value(settings).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let settings = GiftMenuSettings {
|
||||
visible_rows: 255,
|
||||
row_height: 1,
|
||||
scroll_speed_pixels_per_second: u16::MAX,
|
||||
font_scale: 1,
|
||||
..GiftMenuSettings::default()
|
||||
};
|
||||
let sanitized = definition
|
||||
.validate_settings(serde_json::to_value(settings).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(sanitized["visibleRows"], 20);
|
||||
assert_eq!(sanitized["rowHeight"], 44);
|
||||
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
|
||||
assert_eq!(sanitized["fontScale"], 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() {
|
||||
let specific = Uuid::new_v4();
|
||||
let battery = Uuid::new_v4();
|
||||
let component = component(vec![
|
||||
GiftMenuItem {
|
||||
id: specific,
|
||||
trigger: GiftMenuTrigger::Gift {
|
||||
gift_id: 31039,
|
||||
gift_name: "心动盲盒".into(),
|
||||
image_url: None,
|
||||
unit_price: 15_000,
|
||||
},
|
||||
description: "点歌".into(),
|
||||
},
|
||||
GiftMenuItem {
|
||||
id: battery,
|
||||
trigger: GiftMenuTrigger::Battery { amount: 150 },
|
||||
description: "任选挑战".into(),
|
||||
},
|
||||
]);
|
||||
let event = LiveEvent::new(
|
||||
component.owner_id,
|
||||
component.account_source_id,
|
||||
"bilibili",
|
||||
"123",
|
||||
LiveEventPayload::Gift(GiftEvent {
|
||||
viewer: viewer(),
|
||||
gift: GiftDetails {
|
||||
id: Some(31039),
|
||||
name: "心动盲盒".into(),
|
||||
coin_type: "gold".into(),
|
||||
battery_value: crate::overlay::gift_price_to_batteries(15_000),
|
||||
unit_price: 15_000,
|
||||
total_price: 30_000,
|
||||
price_cny: 30.0,
|
||||
image_url: None,
|
||||
animation_url: None,
|
||||
effect_type: None,
|
||||
stay_time: None,
|
||||
},
|
||||
quantity: 2,
|
||||
source_event_id: "event".into(),
|
||||
}),
|
||||
);
|
||||
let message = GiftMenuProjection
|
||||
.project(&component, &event)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(message.event_type, "gift-menu.triggered");
|
||||
assert_eq!(message.payload["itemIds"], json!([specific]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gift_projection_falls_back_to_the_unit_battery_value() {
|
||||
let battery = Uuid::new_v4();
|
||||
let component = component(vec![
|
||||
GiftMenuItem {
|
||||
id: Uuid::new_v4(),
|
||||
trigger: GiftMenuTrigger::Gift {
|
||||
gift_id: 999,
|
||||
gift_name: "其他礼物".into(),
|
||||
image_url: None,
|
||||
unit_price: 15_000,
|
||||
},
|
||||
description: "其他内容".into(),
|
||||
},
|
||||
GiftMenuItem {
|
||||
id: battery,
|
||||
trigger: GiftMenuTrigger::Battery { amount: 150 },
|
||||
description: "任选挑战".into(),
|
||||
},
|
||||
]);
|
||||
let event = LiveEvent::new(
|
||||
component.owner_id,
|
||||
component.account_source_id,
|
||||
"bilibili",
|
||||
"123",
|
||||
LiveEventPayload::Gift(GiftEvent {
|
||||
viewer: viewer(),
|
||||
gift: GiftDetails {
|
||||
id: Some(31039),
|
||||
name: "心动盲盒".into(),
|
||||
coin_type: "gold".into(),
|
||||
battery_value: 150,
|
||||
unit_price: 15_000,
|
||||
total_price: 15_000,
|
||||
price_cny: 15.0,
|
||||
image_url: None,
|
||||
animation_url: None,
|
||||
effect_type: None,
|
||||
stay_time: None,
|
||||
},
|
||||
quantity: 1,
|
||||
source_event_id: "event".into(),
|
||||
}),
|
||||
);
|
||||
let message = GiftMenuProjection
|
||||
.project(&component, &event)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(message.payload["itemIds"], json!([battery]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_projection_normalizes_all_membership_levels() {
|
||||
let id = Uuid::new_v4();
|
||||
let component = component(vec![GiftMenuItem {
|
||||
id,
|
||||
trigger: GiftMenuTrigger::Guard {
|
||||
level: GuardLevel::Admiral,
|
||||
},
|
||||
description: "专属节目".into(),
|
||||
}]);
|
||||
let event = LiveEvent::new(
|
||||
component.owner_id,
|
||||
component.account_source_id,
|
||||
"bilibili",
|
||||
"123",
|
||||
LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
|
||||
viewer: viewer(),
|
||||
guard_name: "提督".into(),
|
||||
quantity: 1,
|
||||
price: 199_800,
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
GiftMenuProjection
|
||||
.project(&component, &event)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ use crate::{
|
||||
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
|
||||
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer,
|
||||
},
|
||||
gift_menu::GIFT_MENU_KIND,
|
||||
repository::{ComponentView, RepositoryError},
|
||||
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
|
||||
};
|
||||
@@ -89,6 +90,14 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/v1/components/{id}/test-events",
|
||||
post(component_test_event),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/components/{id}/gift-catalog",
|
||||
get(component_gift_catalog),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/components/{id}/gift-catalog/refresh",
|
||||
post(refresh_component_gift_catalog),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/components/{public_id}/stream",
|
||||
get(component_stream),
|
||||
@@ -741,6 +750,59 @@ async fn put_component_settings(
|
||||
Ok(Json(json!({"settings":component.settings})))
|
||||
}
|
||||
|
||||
async fn component_gift_catalog(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let session = require_session(&state, &headers).await?;
|
||||
let component = state.repository.get_component(session.user.id, id).await?;
|
||||
if component.kind != GIFT_MENU_KIND {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"not_found",
|
||||
"Gift catalog is unavailable for this component",
|
||||
));
|
||||
}
|
||||
let catalog = state.gift_catalogs.catalog(session.user.id).await;
|
||||
if catalog.is_empty().await {
|
||||
catalog
|
||||
.refresh(
|
||||
&session.user.room_id,
|
||||
state.config.gift_request_timeout_seconds,
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
}
|
||||
Ok(Json(json!({"gifts":catalog.list().await})))
|
||||
}
|
||||
|
||||
async fn refresh_component_gift_catalog(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
let component = state.repository.get_component(session.user.id, id).await?;
|
||||
if component.kind != GIFT_MENU_KIND {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"not_found",
|
||||
"Gift catalog is unavailable for this component",
|
||||
));
|
||||
}
|
||||
let catalog = state.gift_catalogs.catalog(session.user.id).await;
|
||||
catalog
|
||||
.refresh(
|
||||
&session.user.room_id,
|
||||
state.config.gift_request_timeout_seconds,
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(Json(json!({"gifts":catalog.list().await})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SongRequestsQuery {
|
||||
@@ -907,6 +969,8 @@ enum TestEventRequest {
|
||||
name: String,
|
||||
#[serde(rename = "giftName")]
|
||||
gift_name: String,
|
||||
#[serde(rename = "giftId")]
|
||||
gift_id: Option<i64>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
},
|
||||
@@ -943,18 +1007,23 @@ async fn component_test_event(
|
||||
uid,
|
||||
name,
|
||||
gift_name,
|
||||
gift_id,
|
||||
battery,
|
||||
quantity,
|
||||
} => {
|
||||
let quantity = quantity.max(1);
|
||||
let unit_price = i64::from(battery.max(0));
|
||||
// The test API accepts the user-facing battery value while the
|
||||
// canonical event retains Bilibili's raw gold-coin price.
|
||||
let battery_value = i64::from(battery.max(0));
|
||||
let unit_price = battery_value.saturating_mul(100);
|
||||
let total_price = unit_price.saturating_mul(i64::from(quantity));
|
||||
LiveEventPayload::Gift(GiftEvent {
|
||||
viewer: viewer(uid, name),
|
||||
gift: GiftDetails {
|
||||
id: None,
|
||||
id: gift_id,
|
||||
name: gift_name,
|
||||
coin_type: "gold".into(),
|
||||
battery_value,
|
||||
unit_price,
|
||||
total_price,
|
||||
price_cny: total_price as f64 / 1000.0,
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod credentials;
|
||||
pub mod db;
|
||||
pub mod domain;
|
||||
pub mod gift_effect;
|
||||
pub mod gift_menu;
|
||||
pub mod http_api;
|
||||
pub mod i18n;
|
||||
pub mod live;
|
||||
|
||||
@@ -29,7 +29,9 @@ use crate::{
|
||||
UnknownLiveEvent, ViewerInteractionEvent,
|
||||
},
|
||||
live::{LiveProvider, SourceContext, SourceStatus},
|
||||
overlay::{EmoticonCatalog, EmoticonMeta, GiftCatalog, normalize_image_url},
|
||||
overlay::{
|
||||
EmoticonCatalog, EmoticonMeta, GiftCatalog, gift_price_to_batteries, normalize_image_url,
|
||||
},
|
||||
};
|
||||
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20);
|
||||
@@ -53,10 +55,28 @@ impl BilibiliProvider {
|
||||
gift_timeout_seconds: u64,
|
||||
emoticon_refresh_seconds: u64,
|
||||
emoticon_timeout_seconds: u64,
|
||||
) -> Self {
|
||||
Self::with_gift_catalog(
|
||||
cookie,
|
||||
gift_refresh_seconds,
|
||||
gift_timeout_seconds,
|
||||
emoticon_refresh_seconds,
|
||||
emoticon_timeout_seconds,
|
||||
GiftCatalog::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_gift_catalog(
|
||||
cookie: String,
|
||||
gift_refresh_seconds: u64,
|
||||
gift_timeout_seconds: u64,
|
||||
emoticon_refresh_seconds: u64,
|
||||
emoticon_timeout_seconds: u64,
|
||||
gift_catalog: GiftCatalog,
|
||||
) -> Self {
|
||||
Self {
|
||||
cookie: cookie.into(),
|
||||
gift_catalog: GiftCatalog::default(),
|
||||
gift_catalog,
|
||||
emoticon_catalog: EmoticonCatalog::default(),
|
||||
gift_refresh_seconds,
|
||||
gift_timeout_seconds,
|
||||
@@ -149,7 +169,7 @@ impl BilibiliProvider {
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
unit_price,
|
||||
quantity,
|
||||
event_id,
|
||||
} => LiveEventPayload::Gift(GiftEvent {
|
||||
@@ -159,7 +179,7 @@ impl BilibiliProvider {
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
unit_price,
|
||||
quantity,
|
||||
)
|
||||
.await,
|
||||
@@ -171,7 +191,7 @@ impl BilibiliProvider {
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
unit_price,
|
||||
quantity,
|
||||
combo_id,
|
||||
} => LiveEventPayload::GiftCombo(GiftComboEvent {
|
||||
@@ -181,7 +201,7 @@ impl BilibiliProvider {
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
unit_price,
|
||||
quantity,
|
||||
)
|
||||
.await,
|
||||
@@ -416,7 +436,7 @@ enum ProviderEvent {
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
coin_type: Option<String>,
|
||||
battery: i32,
|
||||
unit_price: i64,
|
||||
quantity: i32,
|
||||
event_id: String,
|
||||
},
|
||||
@@ -425,7 +445,7 @@ enum ProviderEvent {
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
coin_type: Option<String>,
|
||||
battery: i32,
|
||||
unit_price: i64,
|
||||
quantity: i32,
|
||||
combo_id: String,
|
||||
},
|
||||
@@ -523,15 +543,64 @@ fn normalize_danmaku(message: &DanmuMessage) -> Option<ProviderEvent> {
|
||||
fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
|
||||
let uid = message.data.uid?;
|
||||
let quantity = bounded_i32(message.data.num.unwrap_or(1)).max(1);
|
||||
let unit_price = message.data.price.or_else(|| {
|
||||
let blind_gift = message.data.extra.get("blind_gift");
|
||||
let original_gift_id = blind_gift
|
||||
.and_then(|value| value.get("original_gift_id"))
|
||||
.and_then(value_i64);
|
||||
let original_gift_name = blind_gift
|
||||
.and_then(|value| value.get("original_gift_name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let original_price = blind_gift
|
||||
.and_then(|value| value.get("original_price"))
|
||||
.and_then(value_i64);
|
||||
// SEND_GIFT_V2 exposes the actual paid unit value as discount_price.
|
||||
// Legacy events also carry this field for discounted gifts. Prefer it to
|
||||
// the catalog/list price, while an explicit blind-box original price stays
|
||||
// authoritative when present.
|
||||
let discount_price = message
|
||||
.data
|
||||
.extra
|
||||
.get("discount_price")
|
||||
.and_then(value_i64)
|
||||
.filter(|price| *price > 0);
|
||||
let unit_price = original_price
|
||||
.or(discount_price)
|
||||
.or_else(|| message.data.price.and_then(bounded_i64))
|
||||
.or_else(|| {
|
||||
message
|
||||
.data
|
||||
.total_coin
|
||||
.and_then(bounded_i64)
|
||||
.map(|total| total / i64::from(quantity))
|
||||
});
|
||||
let gift_id = original_gift_id.or_else(|| message.data.gift_id.and_then(bounded_i64));
|
||||
let gift_name = original_gift_name.unwrap_or_else(|| {
|
||||
message
|
||||
.data
|
||||
.total_coin
|
||||
.map(|total| total / u64::try_from(quantity).unwrap_or(1))
|
||||
.gift_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "礼物".into())
|
||||
});
|
||||
info!(
|
||||
gift_id = gift_id.unwrap_or_default(),
|
||||
gift_name = %gift_name,
|
||||
blind_box = blind_gift.is_some(),
|
||||
raw_unit_price = unit_price.unwrap_or_default(),
|
||||
battery_value = gift_price_to_batteries(unit_price.unwrap_or_default()),
|
||||
quantity,
|
||||
"normalized gift event"
|
||||
);
|
||||
let event_id = message
|
||||
.message_id
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
message
|
||||
.data
|
||||
.extra
|
||||
.get("transaction_id")
|
||||
.and_then(value_lossless_string)
|
||||
})
|
||||
.or_else(|| {
|
||||
message
|
||||
.data
|
||||
@@ -561,14 +630,14 @@ fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
},
|
||||
name: message
|
||||
.data
|
||||
.gift_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "礼物".into()),
|
||||
gift_id: message.data.gift_id.and_then(bounded_i64),
|
||||
// Blind-box SEND_GIFT events describe the revealed prize in the
|
||||
// ordinary gift fields. The `blind_gift.original_*` fields identify
|
||||
// what the viewer actually bought, which is the correct menu trigger
|
||||
// and value source.
|
||||
name: gift_name,
|
||||
gift_id,
|
||||
coin_type: message.data.coin_type.clone(),
|
||||
battery: bounded_i32(unit_price.unwrap_or_default()),
|
||||
unit_price: unit_price.unwrap_or_default().max(0),
|
||||
quantity,
|
||||
event_id,
|
||||
})
|
||||
@@ -613,7 +682,7 @@ fn normalize_combo(message: &ComboSendMessage) -> Option<ProviderEvent> {
|
||||
.unwrap_or_else(|| "礼物".into()),
|
||||
gift_id,
|
||||
coin_type: message.data.coin_type.clone(),
|
||||
battery: bounded_i32(unit_price.unwrap_or_default()),
|
||||
unit_price: unit_price.and_then(bounded_i64).unwrap_or_default().max(0),
|
||||
quantity,
|
||||
combo_id,
|
||||
})
|
||||
@@ -717,23 +786,26 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
"SEND_GIFT" => Some(ProviderEvent::Gift {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
.get("giftName")
|
||||
.pointer("/blind_gift/original_gift_name")
|
||||
.or_else(|| data.get("giftName"))
|
||||
.or_else(|| data.get("gift_name"))?
|
||||
.as_str()?
|
||||
.to_owned(),
|
||||
gift_id: data
|
||||
.get("giftId")
|
||||
.pointer("/blind_gift/original_gift_id")
|
||||
.or_else(|| data.get("giftId"))
|
||||
.or_else(|| data.get("gift_id"))
|
||||
.and_then(Value::as_i64),
|
||||
coin_type: data
|
||||
.get("coin_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
battery: data
|
||||
.get("price")
|
||||
unit_price: data
|
||||
.pointer("/blind_gift/original_price")
|
||||
.or_else(|| data.get("price"))
|
||||
.and_then(value_i64)
|
||||
.map(bounded_signed_i32)
|
||||
.unwrap_or(0),
|
||||
.unwrap_or(0)
|
||||
.max(0),
|
||||
quantity: data
|
||||
.get("num")
|
||||
.and_then(value_i64)
|
||||
@@ -1065,14 +1137,18 @@ async fn gift_details(
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
coin_type: Option<String>,
|
||||
battery: i32,
|
||||
event_unit_price: i64,
|
||||
quantity: i32,
|
||||
) -> GiftDetails {
|
||||
let metadata = catalog.get(gift_id, &name).await;
|
||||
let unit_price = metadata
|
||||
.as_ref()
|
||||
.map(|gift| gift.unit_price)
|
||||
.unwrap_or_else(|| i64::from(battery.max(0)));
|
||||
let unit_price = if event_unit_price > 0 {
|
||||
event_unit_price
|
||||
} else {
|
||||
metadata
|
||||
.as_ref()
|
||||
.map(|gift| gift.unit_price)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let total_price = unit_price.saturating_mul(i64::from(quantity.max(1)));
|
||||
GiftDetails {
|
||||
id: metadata.as_ref().and_then(|gift| gift.id).or(gift_id),
|
||||
@@ -1083,6 +1159,7 @@ async fn gift_details(
|
||||
coin_type: coin_type
|
||||
.or_else(|| metadata.as_ref().map(|gift| gift.coin_type.clone()))
|
||||
.unwrap_or_else(|| "gold".into()),
|
||||
battery_value: gift_price_to_batteries(unit_price),
|
||||
unit_price,
|
||||
total_price,
|
||||
price_cny: total_price as f64 / 1000.0,
|
||||
@@ -1098,8 +1175,54 @@ async fn gift_details(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use libilibili::websocket::parse_command;
|
||||
|
||||
fn push_varint(bytes: &mut Vec<u8>, mut value: u64) {
|
||||
loop {
|
||||
let mut byte = (value & 0x7f) as u8;
|
||||
value >>= 7;
|
||||
if value != 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
bytes.push(byte);
|
||||
if value == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_field_varint(bytes: &mut Vec<u8>, field: u64, value: u64) {
|
||||
push_varint(bytes, field << 3);
|
||||
push_varint(bytes, value);
|
||||
}
|
||||
|
||||
fn push_field_bytes(bytes: &mut Vec<u8>, field: u64, value: &[u8]) {
|
||||
push_varint(bytes, (field << 3) | 2);
|
||||
push_varint(bytes, value.len() as u64);
|
||||
bytes.extend_from_slice(value);
|
||||
}
|
||||
|
||||
fn gift_v2_payload() -> String {
|
||||
let mut gift = Vec::new();
|
||||
push_field_varint(&mut gift, 1, 20_036);
|
||||
push_field_bytes(&mut gift, 2, "盲盒奖品".as_bytes());
|
||||
push_field_varint(&mut gift, 3, 1);
|
||||
push_field_varint(&mut gift, 5, 50_000);
|
||||
push_field_varint(&mut gift, 6, 50_000);
|
||||
push_field_varint(&mut gift, 7, 15_000);
|
||||
push_field_bytes(&mut gift, 8, b"gold");
|
||||
push_field_bytes(&mut gift, 9, b"gift-v2-transaction");
|
||||
push_field_varint(&mut gift, 10, 1_753_984_699);
|
||||
push_field_bytes(&mut gift, 18, "投喂".as_bytes());
|
||||
|
||||
let mut message = Vec::new();
|
||||
push_field_varint(&mut message, 1, 123);
|
||||
push_field_bytes(&mut message, 2, "V2观众".as_bytes());
|
||||
push_field_bytes(&mut message, 10, &gift);
|
||||
STANDARD.encode(message)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_libilibili_danmaku_to_provider_event() {
|
||||
let message = normalize_command(parse_command(
|
||||
@@ -1151,7 +1274,7 @@ mod tests {
|
||||
viewer,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
unit_price,
|
||||
quantity,
|
||||
event_id,
|
||||
..
|
||||
@@ -1159,7 +1282,7 @@ mod tests {
|
||||
assert_eq!(viewer.uid, "123");
|
||||
assert_eq!(gift_id, Some(42));
|
||||
assert_eq!(coin_type.as_deref(), Some("gold"));
|
||||
assert_eq!(battery, 100);
|
||||
assert_eq!(unit_price, 100);
|
||||
assert_eq!(quantity, 3);
|
||||
assert_eq!(event_id, "gift-event-1");
|
||||
}
|
||||
@@ -1167,6 +1290,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blind_box_uses_the_purchased_gift_identity_and_price() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
"cmd":"SEND_GIFT",
|
||||
"data":{
|
||||
"uid":123,
|
||||
"uname":"盲盒观众",
|
||||
"giftId":20036,
|
||||
"giftName":"盲盒奖品",
|
||||
"num":1,
|
||||
"price":50000,
|
||||
"coin_type":"gold",
|
||||
"blind_gift":{
|
||||
"original_gift_id":20002,
|
||||
"original_gift_name":"心动盲盒",
|
||||
"original_price":15000
|
||||
}
|
||||
}
|
||||
})))
|
||||
.unwrap();
|
||||
match event {
|
||||
ProviderEvent::Gift {
|
||||
gift_id,
|
||||
name,
|
||||
unit_price,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(gift_id, Some(20002));
|
||||
assert_eq!(name, "心动盲盒");
|
||||
assert_eq!(unit_price, 15_000);
|
||||
}
|
||||
_ => panic!("expected gift"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gift_v2_uses_discount_value_and_transaction_identity() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
"cmd":"SEND_GIFT_V2",
|
||||
"data":{"pb":gift_v2_payload()}
|
||||
})))
|
||||
.unwrap();
|
||||
match event {
|
||||
ProviderEvent::Gift {
|
||||
viewer,
|
||||
gift_id,
|
||||
name,
|
||||
unit_price,
|
||||
quantity,
|
||||
event_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(viewer.uid, "123");
|
||||
assert_eq!(viewer.name, "V2观众");
|
||||
assert_eq!(gift_id, Some(20_036));
|
||||
assert_eq!(name, "盲盒奖品");
|
||||
assert_eq!(unit_price, 15_000);
|
||||
assert_eq!(quantity, 1);
|
||||
assert_eq!(event_id, "gift-v2-transaction");
|
||||
}
|
||||
_ => panic!("expected gift"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_typed_combo_commands() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Stable identifier for a renderer theme.
|
||||
///
|
||||
@@ -117,6 +118,7 @@ pub struct GiftMeta {
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
pub coin_type: String,
|
||||
pub battery_value: i64,
|
||||
pub unit_price: i64,
|
||||
pub image_url: Option<String>,
|
||||
pub animation_url: Option<String>,
|
||||
@@ -130,6 +132,24 @@ pub struct GiftCatalog {
|
||||
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
|
||||
}
|
||||
|
||||
/// Account-keyed catalog handles shared by the live provider and control API.
|
||||
/// Each account receives a distinct [`GiftCatalog`], so switching or refreshing
|
||||
/// one room can never replace another tenant's gift metadata.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct GiftCatalogRegistry {
|
||||
catalogs: Arc<RwLock<HashMap<Uuid, GiftCatalog>>>,
|
||||
}
|
||||
|
||||
impl GiftCatalogRegistry {
|
||||
pub async fn catalog(&self, owner_id: Uuid) -> GiftCatalog {
|
||||
if let Some(catalog) = self.catalogs.read().await.get(&owner_id).cloned() {
|
||||
return catalog;
|
||||
}
|
||||
let mut catalogs = self.catalogs.write().await;
|
||||
catalogs.entry(owner_id).or_default().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EmoticonMeta {
|
||||
pub emoji: String,
|
||||
@@ -239,6 +259,21 @@ impl GiftCatalog {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Return a stable, deduplicated control-console view ordered by price and
|
||||
/// name. ID-backed entries are preferred because names are not unique.
|
||||
pub async fn list(&self) -> Vec<GiftMeta> {
|
||||
let mut gifts: Vec<_> = self.by_id.read().await.values().cloned().collect();
|
||||
if gifts.is_empty() {
|
||||
gifts.extend(self.by_name.read().await.values().cloned());
|
||||
}
|
||||
gifts.sort_by(|left, right| {
|
||||
left.unit_price
|
||||
.cmp(&right.unit_price)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
gifts
|
||||
}
|
||||
|
||||
pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_seconds))
|
||||
@@ -285,6 +320,7 @@ fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
|
||||
continue;
|
||||
};
|
||||
let id = item.get("id").and_then(Value::as_i64);
|
||||
let unit_price = item.get("price").and_then(Value::as_i64).unwrap_or(0);
|
||||
let gift = GiftMeta {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
@@ -293,7 +329,8 @@ fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("gold")
|
||||
.to_owned(),
|
||||
unit_price: item.get("price").and_then(Value::as_i64).unwrap_or(0),
|
||||
battery_value: gift_price_to_batteries(unit_price),
|
||||
unit_price,
|
||||
image_url: string_field(item, "img_basic"),
|
||||
animation_url: string_field(item, "gif"),
|
||||
effect_type: item.get("effect").and_then(|value| match value {
|
||||
@@ -314,6 +351,12 @@ fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
|
||||
Ok((ids, names, list.len()))
|
||||
}
|
||||
|
||||
/// Bilibili's gift panel and live messages express paid gift prices in gold
|
||||
/// coins rather than batteries. One battery is 100 gold coins (and ¥0.1).
|
||||
pub fn gift_price_to_batteries(raw_price: i64) -> i64 {
|
||||
raw_price.max(0) / 100
|
||||
}
|
||||
|
||||
fn string_field(value: &Value, name: &str) -> Option<String> {
|
||||
value
|
||||
.get(name)
|
||||
@@ -414,6 +457,29 @@ fn normalize_name(name: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn gift_catalog_registry_isolates_account_snapshots() {
|
||||
let registry = GiftCatalogRegistry::default();
|
||||
let first = registry.catalog(Uuid::new_v4()).await;
|
||||
let second = registry.catalog(Uuid::new_v4()).await;
|
||||
first.by_id.write().await.insert(
|
||||
1,
|
||||
GiftMeta {
|
||||
id: Some(1),
|
||||
name: "小花花".into(),
|
||||
coin_type: "gold".into(),
|
||||
battery_value: 1,
|
||||
unit_price: 100,
|
||||
image_url: None,
|
||||
animation_url: None,
|
||||
effect_type: None,
|
||||
stay_time: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(first.list().await.len(), 1);
|
||||
assert!(second.list().await.is_empty());
|
||||
}
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -425,6 +491,7 @@ mod tests {
|
||||
let (ids, names, count) = parse_catalog(&payload).expect("catalog");
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000);
|
||||
assert_eq!(ids.get(&42).expect("id index").battery_value, 300);
|
||||
assert_eq!(
|
||||
names
|
||||
.get("青玉灯")
|
||||
@@ -435,6 +502,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_raw_gold_coin_prices_to_batteries() {
|
||||
assert_eq!(gift_price_to_batteries(100), 1);
|
||||
assert_eq!(gift_price_to_batteries(15_000), 150);
|
||||
assert_eq!(gift_price_to_batteries(-1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_empty_catalog_without_replacing_the_cache() {
|
||||
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
components::{ComponentInstance, ComponentRegistry},
|
||||
db::{ComponentRecord, Db, DbError},
|
||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||
i18n,
|
||||
realtime::InMemoryComponentStore,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
@@ -113,6 +114,22 @@ impl TenantRepository {
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
&GIFT_MENU_KIND,
|
||||
&GIFT_MENU_NAME,
|
||||
&menu_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -148,7 +165,7 @@ impl TenantRepository {
|
||||
// Every tenant receives this singleton during registration/startup.
|
||||
// Keeping creation internal prevents a second instance from racing the
|
||||
// partial unique index and turning a domain conflict into a DB error.
|
||||
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND) {
|
||||
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND) {
|
||||
return Err(RepositoryError::Forbidden);
|
||||
}
|
||||
let runtime = self
|
||||
@@ -209,7 +226,10 @@ impl TenantRepository {
|
||||
.await?
|
||||
.ok_or(RepositoryError::NotFound)?
|
||||
.get(0);
|
||||
if matches!(kind.as_str(), SONG_REQUEST_KIND | GIFT_EFFECT_KIND) {
|
||||
if matches!(
|
||||
kind.as_str(),
|
||||
SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND
|
||||
) {
|
||||
return Err(RepositoryError::Forbidden);
|
||||
}
|
||||
let changed = transaction
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 组件开发指南
|
||||
|
||||
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、 `song_request` 与
|
||||
`gift_effect`,未来礼物墙或统计组件也应使用同一套契约。
|
||||
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、`song_request`、 `gift_effect`
|
||||
与 `gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
||||
|
||||
## 一个组件由什么组成
|
||||
|
||||
@@ -56,4 +56,5 @@ Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写
|
||||
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
|
||||
|
||||
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与
|
||||
[`song-request.md`](song-request.md)、[`gift-effect.md`](gift-effect.md)。
|
||||
[`song-request.md`](song-request.md)、[`gift-effect.md`](gift-effect.md) 与
|
||||
[`gift-menu.md`](gift-menu.md)。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 礼物菜单组件
|
||||
|
||||
`gift_menu`
|
||||
是每个账户自动拥有且不可删除的单例组件。它把直播间礼物或大航海投喂映射为主播提供的内容说明,并以独立只读 token 输出透明 OBS 浏览器源。
|
||||
|
||||
## 目录与触发器
|
||||
|
||||
账户直播监听器调用 Bilibili `giftPanel/roomGiftList`
|
||||
并定时刷新按账户隔离的内存目录;控制台读取同一份缓存,也可以主动刷新。接口无需 Cookie,返回礼物 ID、名称、价格、静态图和 GIF。字段说明见
|
||||
[礼物 API 文档](https://github.com/pskdje/bilibili-API-collect/blob/main/docs/live/gift.md)。上游失败时保留最后一次成功目录。
|
||||
|
||||
菜单项按顺序保存在经过后端校验的组件 settings 中,最多 100 项,支持:
|
||||
|
||||
- 指定礼物 ID;事件缺少 ID 时以保存的名称降级匹配;
|
||||
- 舰长、提督或总督;
|
||||
- 指定礼物单价,单位为电池,例如 `150`。
|
||||
|
||||
礼物事件先匹配指定礼物 ID(事件缺少 ID 时匹配名称);命中后只高亮具体礼物。如果没有具体礼物命中,才按单价电池数回退匹配。组件只消费
|
||||
`live.gift`,不消费连击更新,避免重复触发。Bilibili 接口中的 `price` 是金瓜子而不是电池;后端统一按
|
||||
`price / 100` 换算(例如 `100` 金瓜子为 `1` 电池)。盲盒事件优先使用 `blind_gift.original_*`
|
||||
的盲盒 ID、名称与原价,不使用开出的奖品价值。对于 protobuf
|
||||
`SEND_GIFT_V2`,后端复用同一礼物管线,并优先使用非零 `discount_price`
|
||||
作为实际支付单价;`transaction_id` 用作稳定事件 ID。当前 V2
|
||||
schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电池价值回退匹配,但不能保证命中具体盲盒条目。
|
||||
|
||||
## OBS 行为
|
||||
|
||||
每行横向展示图标、触发条件与主播自定义说明。行数、行高、文字比例、滚动速度、高亮时长和动效强度均可调整。仅当内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
|
||||
|
||||
命中时浏览器选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行、暂停自动滚动并播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子。舰长、提督和总督使用随前端打包的透明图标。
|
||||
|
||||
第一版主题 `jade-banquet` 复用仓库内已有且记录许可的花枝 SVG;新增主题必须在 `giftMenuThemes.ts`
|
||||
注册稳定 ID、资源键和局部 CSS 变量。
|
||||
@@ -115,6 +115,10 @@ close 结束连接。
|
||||
`gift.animationUrl`
|
||||
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
|
||||
|
||||
`gift_menu` 同样只消费一次性 `live.gift` 与 `live.guard.buy`。命中配置后投影为
|
||||
`gift-menu.triggered`,payload 包含 `itemIds`、`viewer` 和
|
||||
`sourceEventId`。一个特定礼物和一个同价电池规则可以同时命中多个菜单项;客户端应全部高亮,并滚动到第一个匹配项。未命中的投喂不会进入该组件通道。
|
||||
|
||||
## 表情分段
|
||||
|
||||
`live.danmaku.payload.segments` 是判别联合:
|
||||
|
||||
@@ -223,6 +223,8 @@ name = "简体中文"
|
||||
"test.nickname" = "测试昵称"
|
||||
"test.danmaku_text" = "弹幕内容"
|
||||
"test.gift_name" = "礼物名称"
|
||||
"test.gift_id" = "礼物 ID(可选)"
|
||||
"test.gift_id_placeholder" = "用于测试指定礼物菜单项"
|
||||
"test.quantity" = "数量"
|
||||
"test.battery" = "电池数"
|
||||
"test.guard" = "上舰"
|
||||
@@ -237,10 +239,12 @@ name = "简体中文"
|
||||
"components.danmaku_mark" = "弹"
|
||||
"components.song_mark" = "歌"
|
||||
"components.gift_mark" = "礼"
|
||||
"components.gift_menu_mark" = "单"
|
||||
"components.generic_mark" = "件"
|
||||
"components.danmaku_type" = "直播弹幕姬"
|
||||
"components.song_type" = "直播点歌姬"
|
||||
"components.gift_type" = "全屏礼物星雨"
|
||||
"components.gift_menu_type" = "直播礼物菜单"
|
||||
"components.coming_soon" = "即将支持"
|
||||
"components.future" = "更多主题 · 互动组件"
|
||||
"components.settings_blocker" = "保存或还原当前组件设置"
|
||||
@@ -255,6 +259,8 @@ name = "简体中文"
|
||||
"components.song_description" = "观众发送「点歌 歌名」入队,发送「打分 1-5」评价当前歌曲。"
|
||||
"components.gift_settings" = "全屏礼物特效设置"
|
||||
"components.gift_description" = "礼物化作流星横跨透明画布;舰长、提督和总督触发全屏星光献礼。"
|
||||
"components.gift_menu_settings" = "礼物菜单设置"
|
||||
"components.gift_menu_description" = "把当前直播间的礼物、大航海身份或指定电池单价映射为主播提供的直播内容。"
|
||||
"components.open_song_stats" = "打开点歌统计"
|
||||
"components.generic_settings" = "组件设置"
|
||||
"components.no_editor" = "该组件类型的设置编辑器尚未安装。"
|
||||
@@ -391,6 +397,54 @@ name = "简体中文"
|
||||
"gift.guard_salute" = "星河为你闪耀"
|
||||
"gift.guard_title" = "{guard}·星光献礼"
|
||||
"gift.guard_viewer" = "感谢 {viewer} 的守护"
|
||||
"gift_menu.theme.jade_banquet.name" = "青玉华宴"
|
||||
"gift_menu.theme.jade_banquet.description" = "青玉玻璃、流金花枝和星光高亮组成的古风礼物菜单。"
|
||||
"gift_menu.catalog.title" = "当前直播间礼物目录"
|
||||
"gift_menu.catalog.loading" = "正在读取礼物与图标…"
|
||||
"gift_menu.catalog.count" = "已加载 {count} 种礼物"
|
||||
"gift_menu.catalog.refresh" = "立即刷新礼物目录"
|
||||
"gift_menu.catalog.failed" = "礼物目录读取失败"
|
||||
"gift_menu.editor.add_title" = "添加菜单项"
|
||||
"gift_menu.editor.trigger_type" = "触发类型"
|
||||
"gift_menu.trigger.gift" = "指定普通礼物"
|
||||
"gift_menu.trigger.guard" = "指定大航海身份"
|
||||
"gift_menu.trigger.battery" = "指定礼物单价"
|
||||
"gift_menu.editor.gift" = "直播间礼物"
|
||||
"gift_menu.editor.gift_option" = "{name} · {battery} 电池"
|
||||
"gift_menu.editor.guard_level" = "大航海身份"
|
||||
"gift_menu.editor.battery_amount" = "单价电池数"
|
||||
"gift_menu.editor.description" = "对应直播内容"
|
||||
"gift_menu.editor.description_placeholder" = "例如:点歌一首 / 学唱一首歌"
|
||||
"gift_menu.editor.add" = "加入礼物菜单"
|
||||
"gift_menu.editor.description_required" = "请填写该礼物对应的直播内容。"
|
||||
"gift_menu.editor.gift_required" = "请先从当前直播间目录选择礼物。"
|
||||
"gift_menu.editor.duplicate" = "这个触发条件已经存在于菜单中。"
|
||||
"gift_menu.editor.empty" = "还没有菜单项,请从上方添加。"
|
||||
"gift_menu.editor.move_up" = "上移菜单项"
|
||||
"gift_menu.editor.move_down" = "下移菜单项"
|
||||
"gift_menu.editor.remove" = "移除"
|
||||
"gift_menu.guard.captain" = "舰长"
|
||||
"gift_menu.guard.admiral" = "提督"
|
||||
"gift_menu.guard.governor" = "总督"
|
||||
"gift_menu.guard_mark.captain" = "舰"
|
||||
"gift_menu.guard_mark.admiral" = "提"
|
||||
"gift_menu.guard_mark.governor" = "总"
|
||||
"gift_menu.battery_mark" = "电"
|
||||
"gift_menu.settings.visible_rows" = "最多展示行数"
|
||||
"gift_menu.settings.row_height" = "每行高度"
|
||||
"gift_menu.settings.scroll_speed" = "循环滚动速度"
|
||||
"gift_menu.settings.highlight_duration" = "触发高亮时长"
|
||||
"gift_menu.settings.font_scale" = "文字缩放"
|
||||
"gift_menu.settings.motion" = "动效强度"
|
||||
"gift_menu.unit.pixels" = "{value} 像素"
|
||||
"gift_menu.unit.speed" = "{value} 像素/秒"
|
||||
"gift_menu.unit.seconds" = "{value} 秒"
|
||||
"gift_menu.preview.title" = "礼物菜单预览"
|
||||
"gift_menu.preview.description" = "点击任意菜单行可预览定位、暂停滚动与渐变高亮动画。"
|
||||
"gift_menu.aria" = "直播间礼物菜单"
|
||||
"gift_menu.overlay.battery" = "电池 ×{amount}"
|
||||
"gift_menu.overlay.triggered_by" = "{viewer} 已触发"
|
||||
"gift_menu.overlay.empty" = "请先在控制台配置礼物菜单"
|
||||
"pwa.blocked" = "暂时不能更新,请先处理以下内容:\n\n{reasons}"
|
||||
"pwa.confirm_update" = "更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?"
|
||||
"pwa.offline" = "离线"
|
||||
@@ -621,6 +675,8 @@ name = "English"
|
||||
"test.nickname" = "Test nickname"
|
||||
"test.danmaku_text" = "Chat message"
|
||||
"test.gift_name" = "Gift name"
|
||||
"test.gift_id" = "Gift ID (optional)"
|
||||
"test.gift_id_placeholder" = "Tests a specific gift-menu entry"
|
||||
"test.quantity" = "Quantity"
|
||||
"test.battery" = "Battery value"
|
||||
"test.guard" = "Guard purchase"
|
||||
@@ -635,10 +691,12 @@ name = "English"
|
||||
"components.danmaku_mark" = "Chat"
|
||||
"components.song_mark" = "Song"
|
||||
"components.gift_mark" = "Gift"
|
||||
"components.gift_menu_mark" = "Menu"
|
||||
"components.generic_mark" = "App"
|
||||
"components.danmaku_type" = "Live chat overlay"
|
||||
"components.song_type" = "Song request overlay"
|
||||
"components.gift_type" = "Full-screen gift starfall"
|
||||
"components.gift_menu_type" = "Live gift menu"
|
||||
"components.coming_soon" = "Coming soon"
|
||||
"components.future" = "More themes · Interactive components"
|
||||
"components.settings_blocker" = "save or revert the current component settings"
|
||||
@@ -653,6 +711,8 @@ name = "English"
|
||||
"components.song_description" = "Viewers send “点歌 Song name” to queue a song and “打分 1-5” to rate the current song."
|
||||
"components.gift_settings" = "Full-screen gift effect settings"
|
||||
"components.gift_description" = "Gifts become meteors crossing a transparent canvas; Guard, Admiral, and Governor purchases trigger a full-screen starlight celebration."
|
||||
"components.gift_menu_settings" = "Gift menu settings"
|
||||
"components.gift_menu_description" = "Map gifts available in the current room, membership tiers, or a gift unit price to content the streamer will perform."
|
||||
"components.open_song_stats" = "Open song statistics"
|
||||
"components.generic_settings" = "Component settings"
|
||||
"components.no_editor" = "No settings editor is installed for this component kind."
|
||||
@@ -789,6 +849,54 @@ name = "English"
|
||||
"gift.guard_salute" = "THE STARS SHINE FOR YOU"
|
||||
"gift.guard_title" = "{guard} · STARLIGHT TRIBUTE"
|
||||
"gift.guard_viewer" = "Thank you, {viewer}, for your support"
|
||||
"gift_menu.theme.jade_banquet.name" = "Jade Banquet"
|
||||
"gift_menu.theme.jade_banquet.description" = "An ornate gift menu of jade glass, gilded blossoms, and starlight highlights."
|
||||
"gift_menu.catalog.title" = "Current room gift catalog"
|
||||
"gift_menu.catalog.loading" = "Loading gifts and icons…"
|
||||
"gift_menu.catalog.count" = "Loaded {count} gifts"
|
||||
"gift_menu.catalog.refresh" = "Refresh gift catalog"
|
||||
"gift_menu.catalog.failed" = "Could not load the gift catalog"
|
||||
"gift_menu.editor.add_title" = "Add menu item"
|
||||
"gift_menu.editor.trigger_type" = "Trigger type"
|
||||
"gift_menu.trigger.gift" = "Specific regular gift"
|
||||
"gift_menu.trigger.guard" = "Specific membership tier"
|
||||
"gift_menu.trigger.battery" = "Specific gift unit price"
|
||||
"gift_menu.editor.gift" = "Room gift"
|
||||
"gift_menu.editor.gift_option" = "{name} · {battery} battery"
|
||||
"gift_menu.editor.guard_level" = "Membership tier"
|
||||
"gift_menu.editor.battery_amount" = "Unit price in battery"
|
||||
"gift_menu.editor.description" = "Stream content"
|
||||
"gift_menu.editor.description_placeholder" = "For example: request a song / learn a song"
|
||||
"gift_menu.editor.add" = "Add to gift menu"
|
||||
"gift_menu.editor.description_required" = "Describe the content triggered by this gift."
|
||||
"gift_menu.editor.gift_required" = "Select a gift from the current room catalog first."
|
||||
"gift_menu.editor.duplicate" = "This trigger is already present in the menu."
|
||||
"gift_menu.editor.empty" = "No menu items yet. Add one above."
|
||||
"gift_menu.editor.move_up" = "Move menu item up"
|
||||
"gift_menu.editor.move_down" = "Move menu item down"
|
||||
"gift_menu.editor.remove" = "Remove"
|
||||
"gift_menu.guard.captain" = "Guard"
|
||||
"gift_menu.guard.admiral" = "Admiral"
|
||||
"gift_menu.guard.governor" = "Governor"
|
||||
"gift_menu.guard_mark.captain" = "G"
|
||||
"gift_menu.guard_mark.admiral" = "A"
|
||||
"gift_menu.guard_mark.governor" = "V"
|
||||
"gift_menu.battery_mark" = "B"
|
||||
"gift_menu.settings.visible_rows" = "Maximum visible rows"
|
||||
"gift_menu.settings.row_height" = "Row height"
|
||||
"gift_menu.settings.scroll_speed" = "Loop scroll speed"
|
||||
"gift_menu.settings.highlight_duration" = "Trigger highlight duration"
|
||||
"gift_menu.settings.font_scale" = "Text scale"
|
||||
"gift_menu.settings.motion" = "Motion intensity"
|
||||
"gift_menu.unit.pixels" = "{value} px"
|
||||
"gift_menu.unit.speed" = "{value} px/s"
|
||||
"gift_menu.unit.seconds" = "{value} s"
|
||||
"gift_menu.preview.title" = "Gift menu preview"
|
||||
"gift_menu.preview.description" = "Click any row to preview targeting, scroll pause, and the gradient highlight animation."
|
||||
"gift_menu.aria" = "Live room gift menu"
|
||||
"gift_menu.overlay.battery" = "Battery ×{amount}"
|
||||
"gift_menu.overlay.triggered_by" = "Triggered by {viewer}"
|
||||
"gift_menu.overlay.empty" = "Configure the gift menu in the control console"
|
||||
"pwa.blocked" = "The update is blocked. Finish these items first:\n\n{reasons}"
|
||||
"pwa.confirm_update" = "Updating refreshes the console. Save settings, invitations, recovery codes, and newly rotated OBS tokens first. Update now?"
|
||||
"pwa.offline" = "Offline"
|
||||
|
||||
Reference in New Issue
Block a user