This commit is contained in:
2026-08-06 10:38:07 -07:00
parent ac80cd1db4
commit 97a3f1be48
29 changed files with 1008 additions and 115 deletions
+3
View File
@@ -25,3 +25,6 @@
The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally,
so OBS never needs to load the source websites.
`moonlit-edge.svg` is an original repository asset. It is a transparent, monochrome mask shared by
the Moonlit Water danmaku, song-request, and gift-menu components.
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 40" role="img" aria-label="">
<!--
Repository-original ornament for the Moonlit Water theme. The monochrome
alpha shape is used as a CSS mask, allowing each component to tint it with
the same restrained jade-and-gold palette.
-->
<g fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 20h370c34 0 49-5 69-15-8 10-8 20 0 30-20-10-35-15-69-15" opacity=".62" />
<path d="M1190 20H820c-34 0-49-5-69-15 8 10 8 20 0 30 20-10 35-15 69-15" opacity=".62" />
<path d="M48 20c15-10 31-10 46 0-15 10-31 10-46 0Zm1104 0c-15-10-31-10-46 0 15 10 31 10 46 0Z" opacity=".72" />
<path d="M533 20c21-4 36-12 47-18-2 9 5 14 20 18-15 4-22 9-20 18-11-6-26-14-47-18Z" />
<path d="M667 20c-21-4-36-12-47-18 2 9-5 14-20 18 15 4 22 9 20 18 11-6 26-14 47-18Z" />
<path d="M563 20c13-8 25-8 37 0-12 8-24 8-37 0Zm74 0c-13-8-25-8-37 0 12 8 24 8 37 0Z" opacity=".74" />
</g>
<g fill="#000">
<circle cx="112" cy="20" r="2" opacity=".78" />
<circle cx="1088" cy="20" r="2" opacity=".78" />
<circle cx="488" cy="20" r="2.2" />
<circle cx="712" cy="20" r="2.2" />
<path d="m600 10 3 7 7 3-7 3-3 7-3-7-7-3 7-3Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -214,9 +214,17 @@ export function normalizeSettings(value: unknown): OverlaySettings {
...(settings as Partial<OverlaySettings>),
themeId: normalizeThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
viewerColor: normalizeOptionalHexColor(settings.viewerColor),
danmakuColor: normalizeOptionalHexColor(settings.danmakuColor),
}
}
function normalizeOptionalHexColor(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim().toUpperCase()
return /^#[0-9A-F]{6}$/.test(normalized) ? normalized : null
}
export function normalizeSongRequestSettings(value: unknown): SongRequestSettings {
const root = object(value)
const settings = object(root.settings ?? value)
@@ -225,6 +233,8 @@ export function normalizeSongRequestSettings(value: unknown): SongRequestSetting
...(settings as Partial<SongRequestSettings>),
themeId: normalizeThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
requesterColor: normalizeOptionalHexColor(settings.requesterColor),
songTitleColor: normalizeOptionalHexColor(settings.songTitleColor),
}
}
+44 -2
View File
@@ -741,6 +741,11 @@ body,
color: var(--theme-text, #e7fff9);
}
.card.danmaku .copy > .danmaku-content,
.card.danmaku .copy > .danmaku-content .danmaku-text {
color: var(--component-danmaku-color, var(--theme-text, #e7fff9));
}
.danmaku-content {
line-height: 1.35;
}
@@ -852,7 +857,7 @@ body,
}
.card.danmaku.expanded .copy b {
color: var(--theme-user, #f0fffb);
color: var(--component-viewer-color, var(--theme-user, #f0fffb));
font-size: 0.78em;
letter-spacing: 0.06em;
}
@@ -884,7 +889,7 @@ body,
.card.danmaku.compact .copy b {
max-width: none;
flex: 0 0 auto;
color: var(--theme-compact-user, #aeece1);
color: var(--component-viewer-color, var(--theme-compact-user, #aeece1));
}
.card.danmaku.compact .copy span {
@@ -1069,15 +1074,21 @@ body,
.theme-moonlit-water .card.danmaku .copy > b {
grid-column: 2;
grid-row: 1;
color: var(--component-viewer-color, #b7ad83);
line-height: 1.25;
}
.theme-moonlit-water .card.danmaku .copy > .danmaku-content {
grid-column: 1 / -1;
grid-row: 2;
color: var(--component-danmaku-color, #c6ae7a);
line-height: 1.35;
}
.theme-moonlit-water .card.danmaku .copy > .danmaku-content .danmaku-text {
color: var(--component-danmaku-color, #c6ae7a);
}
.theme-moonlit-water .card.danmaku .viewer-avatar {
display: grid;
grid-column: 1;
@@ -2030,6 +2041,36 @@ select {
margin-bottom: 24px;
}
.danmaku-color-settings {
max-width: 780px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: -8px 0 24px;
}
.color-picker-control {
display: grid;
grid-template-columns: 54px minmax(82px, 1fr) auto;
gap: 10px;
align-items: center;
}
.field-grid .color-picker-control input[type='color'] {
width: 54px;
min-height: 44px;
padding: 4px;
cursor: pointer;
}
.color-picker-control code {
color: #d9eee8;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
}
.color-picker-control button {
white-space: nowrap;
}
.typography-brightness > span {
display: flex;
justify-content: space-between;
@@ -2291,6 +2332,7 @@ td:last-child {
.totp-enrollment,
.slider-grid,
.typography-settings,
.danmaku-color-settings,
.two-columns {
grid-template-columns: 1fr;
}
+143 -6
View File
@@ -115,6 +115,50 @@ function TypographySettingsFields({
)
}
type TextColorField = {
labelKey: string
color: string | null
themeColor: string
onChange: (color: string | null) => void
}
/** Shared theme-aware color editor used by text-led OBS components. */
function TextColorSettingsFields({ fields }: { fields: TextColorField[] }) {
const field = (
labelKey: string,
color: string | null,
themeColor: string,
onChange: (color: string | null) => void,
) => (
<label key={labelKey}>
<span>{translate(labelKey)}</span>
<div className="color-picker-control">
<input
type="color"
value={color ?? themeColor}
onChange={event => onChange(event.target.value.toUpperCase())}
/>
<code>{color ?? themeColor}</code>
<button
type="button"
className="secondary"
disabled={color === null}
onClick={() => onChange(null)}
>
{translate('settings.use_theme_color')}
</button>
</div>
<small>{translate('settings.text_color_description')}</small>
</label>
)
return (
<div className="field-grid danmaku-color-settings">
{fields.map(item => field(item.labelKey, item.color, item.themeColor, item.onChange))}
</div>
)
}
function isDanmakuKind(kind: string): boolean {
return kind === 'danmaku_overlay' || kind === 'danmaku'
}
@@ -280,6 +324,22 @@ function SettingsEditor({
onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', value)}
/>
<TextColorSettingsFields
fields={[
{
labelKey: 'settings.viewer_color',
color: settings.viewerColor,
themeColor: selectedTheme.palette.user,
onChange: value => edit('viewerColor', value),
},
{
labelKey: 'settings.danmaku_color',
color: settings.danmakuColor,
themeColor: selectedTheme.palette.text,
onChange: value => edit('danmakuColor', value),
},
]}
/>
<div className="slider-grid">
<label>
<span>
@@ -467,6 +527,7 @@ function SongRequestSettingsEditor({
}) {
const edit = <K extends keyof SongRequestSettings>(key: K, value: SongRequestSettings[K]) =>
onChange({ ...settings, [key]: value })
const selectedTheme = getOverlayTheme(settings.themeId)
return (
<div className="settings-editor">
<div className="field-grid theme-selector">
@@ -484,7 +545,7 @@ function SongRequestSettingsEditor({
</option>
))}
</select>
<small>{translate(getOverlayTheme(settings.themeId).descriptionKey)}</small>
<small>{translate(selectedTheme.descriptionKey)}</small>
</label>
</div>
<TypographySettingsFields
@@ -493,6 +554,22 @@ function SongRequestSettingsEditor({
onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', value)}
/>
<TextColorSettingsFields
fields={[
{
labelKey: 'song.settings.requester_color',
color: settings.requesterColor,
themeColor: selectedTheme.palette.user,
onChange: value => edit('requesterColor', value),
},
{
labelKey: 'song.settings.title_color',
color: settings.songTitleColor,
themeColor: selectedTheme.palette.text,
onChange: value => edit('songTitleColor', value),
},
]}
/>
<div className="slider-grid">
<label>
<span>
@@ -1121,7 +1198,12 @@ function GiftMenuSettingsEditor({
<div className="slider-grid gift-menu-renderer-settings">
<label>
<span>
{translate('gift_menu.settings.visible_rows')} <output>{settings.visibleRows}</output>
{translate(
theme.id === 'moonlit-water'
? 'gift_menu.settings.rows_per_page'
: 'gift_menu.settings.visible_rows',
)}{' '}
<output>{settings.visibleRows}</output>
</span>
<input
type="range"
@@ -1149,6 +1231,26 @@ function GiftMenuSettingsEditor({
onChange={event => edit('rowHeight', +event.target.value)}
/>
</label>
{theme.id === 'moonlit-water' ? (
<label>
<span>
{translate('gift_menu.settings.page_interval')}{' '}
<output>
{translate('gift_menu.unit.seconds', {
value: (settings.pageIntervalMs / 1000).toFixed(1),
})}
</output>
</span>
<input
type="range"
min="1500"
max="30000"
step="500"
value={settings.pageIntervalMs}
onChange={event => edit('pageIntervalMs', +event.target.value)}
/>
</label>
) : (
<label>
<span>
{translate('gift_menu.settings.scroll_speed')}{' '}
@@ -1166,6 +1268,7 @@ function GiftMenuSettingsEditor({
onChange={event => edit('scrollSpeedPixelsPerSecond', +event.target.value)}
/>
</label>
)}
<label>
<span>
{translate('gift_menu.settings.highlight_duration')}{' '}
@@ -2259,6 +2362,30 @@ export function SongRequestsPage({
}
}
const clearQueue = async () => {
if (!window.confirm(translate('song.clear_confirm'))) return
setBusyId('clear')
setFlash(undefined)
try {
const result = await api<{ cancelledCount: number }>(
`/api/v1/components/${encodeURIComponent(componentId)}/song-requests/clear`,
json('POST'),
)
setFlash({
kind: 'success',
text: translate('song.cleared', { count: result.cancelledCount }),
})
await load()
} catch (reason) {
setFlash({
kind: 'error',
text: errorMessage(reason, translate('song.clear_failed')),
})
} finally {
setBusyId('')
}
}
const summary = active?.summary ?? historyPage?.summary
return (
<ControlLayout user={user} active="components" onLogout={onLogout}>
@@ -2268,6 +2395,15 @@ export function SongRequestsPage({
<p className="eyebrow">{translate('song.stats_eyebrow')}</p>
<h1>{translate('song.stats_title')}</h1>
</div>
<div className="form-actions">
<button
type="button"
className="danger"
disabled={Boolean(busyId) || (!active?.current && (active?.items.length ?? 0) === 0)}
onClick={() => void clearQueue()}
>
{busyId === 'clear' ? translate('song.clearing') : translate('song.clear_queue')}
</button>
<a
className="button-link secondary"
href={`/control/?component=${encodeURIComponent(componentId)}`}
@@ -2275,6 +2411,7 @@ export function SongRequestsPage({
{translate('song.back_settings')}
</a>
</div>
</div>
<FlashMessage flash={flash} />
<div className="song-stat-grid">
{[
@@ -2312,7 +2449,7 @@ export function SongRequestsPage({
<div className="form-actions">
<button
type="button"
disabled={busyId === active.current.id}
disabled={Boolean(busyId)}
onClick={() => void mutate(active.current!, 'complete')}
>
{translate('song.complete_current')}
@@ -2320,7 +2457,7 @@ export function SongRequestsPage({
<button
type="button"
className="danger"
disabled={busyId === active.current.id}
disabled={Boolean(busyId)}
onClick={() => void mutate(active.current!, 'cancel')}
>
{translate('common.cancel')}
@@ -2351,7 +2488,7 @@ export function SongRequestsPage({
<button
type="button"
className="secondary small"
disabled={busyId === item.id || index === 0}
disabled={Boolean(busyId) || index === 0}
onClick={() => void mutate(item, 'promote')}
>
{translate('song.promote')}
@@ -2359,7 +2496,7 @@ export function SongRequestsPage({
<button
type="button"
className="danger small"
disabled={busyId === item.id}
disabled={Boolean(busyId)}
onClick={() => void mutate(item, 'cancel')}
>
{translate('common.cancel')}
+77 -2
View File
@@ -49,6 +49,16 @@ body,
width: 100%;
}
.gift-menu-page-stack {
height: 100%;
}
.gift-menu-page {
position: absolute;
inset: 0 0 auto;
width: 100%;
}
.gift-menu-row {
position: relative;
isolation: isolate;
@@ -406,14 +416,22 @@ body,
.gift-menu-theme-moonlit-water .gift-menu-viewport {
height: min(100%, var(--menu-panel-height));
border: 0;
border-top: 1px solid rgba(231, 201, 130, 0.34);
border-bottom: 1px solid rgba(231, 201, 130, 0.26);
border-radius: 0;
outline: 0;
background: transparent;
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-page-leaving {
z-index: 1;
animation: moonlit-menu-page-leave var(--menu-page-transition-duration) ease-in-out both;
}
.gift-menu-theme-moonlit-water .gift-menu-page-entering {
z-index: 2;
animation: moonlit-menu-page-enter var(--menu-page-transition-duration) ease-in-out both;
}
.gift-menu-theme-moonlit-water .gift-menu-row,
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n + 2),
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n) {
@@ -528,3 +546,60 @@ body,
filter: brightness(1);
}
}
/* The old page dissolves from its left edge while the next page is revealed
along the same path. This reads as a soft horizontal water-light wipe, not
as a moving list, and remains independent of the OBS viewport dimensions. */
@keyframes moonlit-menu-page-leave {
0% {
opacity: 1;
clip-path: inset(0);
filter: blur(0);
transform: translateX(0);
}
55% {
opacity: 0.48;
}
100% {
opacity: 0;
clip-path: inset(0 0 0 100%);
filter: blur(3px);
transform: translateX(8px);
}
}
@keyframes moonlit-menu-page-enter {
0% {
opacity: 0;
clip-path: inset(0 100% 0 0);
filter: blur(3px);
transform: translateX(-8px);
}
48% {
opacity: 0.56;
}
100% {
opacity: 1;
clip-path: inset(0);
filter: blur(0);
transform: translateX(0);
}
}
.gift-menu-low-motion .gift-menu-page-leaving {
display: none;
}
.gift-menu-low-motion .gift-menu-page-entering {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.gift-menu-page-leaving {
display: none;
}
.gift-menu-page-entering {
animation: none;
}
}
+121 -5
View File
@@ -1,5 +1,5 @@
/** Transparent, infinitely scrolling gift-to-content menu for OBS. */
import { useEffect, useMemo, useRef, useState } from 'react'
/** Transparent gift-to-content menu with theme-selectable motion for OBS. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGiftMenuSettings } from './api'
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemeVariables } from './giftMenuThemes'
@@ -25,6 +25,12 @@ type ActiveTrigger = {
viewer: string
}
type PageTransition = {
currentStart: number
previousStart?: number
nonce: number
}
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}`)
@@ -167,7 +173,31 @@ export function GiftMenuOverlay({
const scrollRemainder = useRef(0)
const [loop, setLoop] = useState(false)
const theme = getGiftMenuTheme(settings.themeId)
const paged = theme.id === 'moonlit-water'
const pageSize = Math.max(1, Math.floor(settings.visibleRows))
const itemIds = settings.items.map(item => item.id).join('\u0000')
const [page, setPage] = useState<PageTransition>({ currentStart: 0, nonce: 0 })
const cycleHeight = settings.items.length * settings.rowHeight
const pageTransitionDurationMs = settings.lowPerformanceMode
? 0
: Math.round(650 + (settings.motionIntensity / 100) * 550)
const showPage = useCallback(
(requestedStart: number) => {
const count = settings.items.length
const nextStart = count === 0 || requestedStart >= count ? 0 : Math.max(0, requestedStart)
setPage(current =>
current.currentStart === nextStart
? current
: {
currentStart: nextStart,
previousStart: current.currentStart,
nonce: current.nonce + 1,
},
)
},
[settings.items.length],
)
useEffect(() => {
if (!preview || !previewTriggeredItemId) return
@@ -181,16 +211,21 @@ export function GiftMenuOverlay({
useEffect(() => {
const node = viewport.current
if (!node) return
if (paged) {
node.scrollTop = 0
setLoop(false)
return
}
const update = () => setLoop(cycleHeight > node.clientHeight + 1)
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [cycleHeight])
}, [cycleHeight, paged])
useEffect(() => {
const node = viewport.current
if (!node || !loop || cycleHeight <= 0) return
if (paged || !node || !loop || cycleHeight <= 0) return
if (node.scrollTop < cycleHeight || node.scrollTop >= cycleHeight * 2)
node.scrollTop = cycleHeight + (node.scrollTop % cycleHeight)
scrollRemainder.current = 0
@@ -218,7 +253,52 @@ export function GiftMenuOverlay({
cancelAnimationFrame(frame)
scrollRemainder.current = 0
}
}, [cycleHeight, loop, settings.scrollSpeedPixelsPerSecond])
}, [cycleHeight, loop, paged, settings.scrollSpeedPixelsPerSecond])
useEffect(() => {
if (!paged) return
setPage(current => ({
currentStart: current.currentStart < settings.items.length ? current.currentStart : 0,
nonce: current.nonce + 1,
}))
}, [itemIds, pageSize, paged, settings.items.length])
useEffect(() => {
if (!paged || page.previousStart === undefined) return
const timer = window.setTimeout(
() =>
setPage(current =>
current.nonce === page.nonce
? { currentStart: current.currentStart, nonce: current.nonce }
: current,
),
pageTransitionDurationMs,
)
return () => window.clearTimeout(timer)
}, [page.nonce, page.previousStart, pageTransitionDurationMs, paged])
useEffect(() => {
if (
!paged ||
effectiveActive ||
settings.items.length <= pageSize ||
settings.pageIntervalMs <= 0
)
return
const timer = window.setTimeout(
() => showPage(page.currentStart + pageSize),
settings.pageIntervalMs,
)
return () => window.clearTimeout(timer)
}, [
effectiveActive,
page.currentStart,
pageSize,
paged,
settings.items.length,
settings.pageIntervalMs,
showPage,
])
useEffect(() => {
const node = viewport.current
@@ -226,6 +306,10 @@ export function GiftMenuOverlay({
if (!node || !id) return
const index = settings.items.findIndex(item => item.id === id)
if (index < 0) return
if (paged) {
showPage(index)
return
}
scrollRemainder.current = 0
if (!loop || cycleHeight <= 0) {
node.scrollTo({
@@ -245,9 +329,11 @@ export function GiftMenuOverlay({
cycleHeight,
effectiveActive?.nonce,
loop,
paged,
settings.highlightDurationMs,
settings.items,
settings.rowHeight,
showPage,
])
useEffect(() => {
@@ -260,6 +346,18 @@ export function GiftMenuOverlay({
}, [effectiveActive?.nonce, preview, settings.highlightDurationMs])
const copies = useMemo(() => (loop ? [0, 1, 2] : [0]), [loop])
const pageRows = (start: number) =>
settings.items
.slice(start, start + pageSize)
.map(item => (
<GiftMenuRow
item={item}
triggered={effectiveActive?.itemIds.includes(item.id) === true}
viewer={effectiveActive?.viewer ?? ''}
onPreviewTrigger={onPreviewTrigger}
key={item.id}
/>
))
return (
<main
className={`gift-menu-overlay ${theme.className} ${preview ? 'gift-menu-preview' : ''} ${settings.lowPerformanceMode ? 'gift-menu-low-motion' : ''}`}
@@ -275,6 +373,7 @@ export function GiftMenuOverlay({
['--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-page-transition-duration' as string]: `${pageTransitionDurationMs}ms`,
['--menu-motion' as string]: settings.motionIntensity / 100,
} as CSSProperties
}
@@ -287,6 +386,23 @@ export function GiftMenuOverlay({
>
{settings.items.length === 0 ? (
<div className="gift-menu-empty">{translate('gift_menu.overlay.empty')}</div>
) : paged ? (
<div className="gift-menu-track gift-menu-page-stack">
{page.previousStart !== undefined && (
<div
className="gift-menu-page gift-menu-page-leaving"
key={`leaving:${page.nonce}:${page.previousStart}`}
>
{pageRows(page.previousStart)}
</div>
)}
<div
className={`gift-menu-page ${page.previousStart !== undefined ? 'gift-menu-page-entering' : ''}`}
key={`current:${page.nonce}:${page.currentStart}`}
>
{pageRows(page.currentStart)}
</div>
</div>
) : (
<div className="gift-menu-track">
{copies.flatMap(copy =>
+4 -1
View File
@@ -13,7 +13,7 @@ export type GiftMenuTheme = {
rose: string
ink: string
}
ornaments: { divider: string; cluster: string; vine: string }
ornaments: { divider: string; cluster: string; vine: string; edge: string }
}
export const giftMenuThemes: readonly GiftMenuTheme[] = [
@@ -33,6 +33,7 @@ export const giftMenuThemes: readonly GiftMenuTheme[] = [
divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.svg',
edge: '/assets/moonlit-edge.svg',
},
},
{
@@ -51,6 +52,7 @@ export const giftMenuThemes: readonly GiftMenuTheme[] = [
divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.svg',
edge: '/assets/moonlit-edge.svg',
},
},
]
@@ -78,5 +80,6 @@ export function giftMenuThemeVariables(theme: GiftMenuTheme): CSSProperties {
['--menu-divider' as string]: `url(${theme.ornaments.divider})`,
['--menu-cluster' as string]: `url(${theme.ornaments.cluster})`,
['--menu-vine' as string]: `url(${theme.ornaments.vine})`,
['--theme-pattern-edge' as string]: `url(${theme.ornaments.edge})`,
}
}
+1
View File
@@ -30,6 +30,7 @@ import './control.css'
import './giftEffect.css'
import './giftMenu.css'
import './song.css'
import './themeEdges.css'
function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) {
const stream = useComponentStream(false, publicId, accessToken)
+2
View File
@@ -411,6 +411,8 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
{
...themeCssVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-viewer-color' as string]: settings.viewerColor || undefined,
['--component-danmaku-color' as string]: settings.danmakuColor || undefined,
['--motion' as string]: `${settings.motionIntensity / 100}`,
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
+214 -52
View File
@@ -68,6 +68,11 @@ body,
font-size: 1.25em;
}
.song-current-mark-note,
.song-waveform {
display: none;
}
.song-current-copy,
.song-score {
position: relative;
@@ -87,11 +92,12 @@ body,
.song-current-copy small,
.song-score small {
color: var(--theme-compact-user);
color: var(--component-song-requester-color, var(--theme-compact-user));
font-size: 0.68em;
}
.song-current-copy strong {
color: var(--component-song-title-color, var(--theme-text));
overflow-wrap: anywhere;
font-size: 1.35em;
line-height: 1.08;
@@ -151,7 +157,7 @@ body,
.song-row {
display: grid;
min-width: 0;
grid-template-columns: 2.5em minmax(0, 1fr) minmax(4em, auto);
grid-template-columns: 2.5em minmax(0, 1fr);
align-items: center;
gap: 0.55em;
padding: 0.4em 0.62em;
@@ -203,8 +209,15 @@ body,
animation-delay: -1.7s;
}
.song-row-copy {
display: grid;
min-width: 0;
gap: 0.12em;
}
.song-row strong {
min-width: 0;
color: var(--component-song-title-color, var(--theme-text));
overflow-wrap: anywhere;
}
@@ -216,8 +229,8 @@ body,
.song-requester {
min-width: 0;
overflow: hidden;
color: var(--theme-compact-user);
text-align: right;
color: var(--component-song-requester-color, var(--theme-compact-user));
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -376,13 +389,15 @@ body,
}
}
/* Moonlit Water keeps the queue as a quiet typeset list: no panel fill, no
glass, and just enough gold rule-work to remain legible over an OBS scene. */
/* Moonlit Water follows the reference's restrained broadcast typography: a
compact current-song lockup above a borderless three-column programme. */
.song-overlay.theme-moonlit-water {
gap: clamp(5px, 0.7vmin, 9px);
padding: clamp(8px, 1.4vmin, 16px);
color: #eadfc3;
gap: clamp(12px, 2vmin, 24px);
padding: clamp(10px, 1.8vmin, 22px);
color: #c6ae7a;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
font-size: var(--song-moonlit-font-size, var(--song-font-size));
letter-spacing: 0.055em;
}
.theme-moonlit-water .song-current,
@@ -400,36 +415,52 @@ body,
}
.theme-moonlit-water .song-current {
min-height: min(var(--song-current-min, 56px), 62px);
min-height: var(--song-moonlit-current-min, 88px);
grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(5px, 0.9vmin, 10px);
padding: 0 0 clamp(6px, 0.9vmin, 10px);
border-bottom: 1px solid rgba(231, 201, 130, 0.38);
gap: clamp(10px, 1.5vmin, 18px);
padding: clamp(6px, 0.8vmin, 10px) 0 clamp(12px, 1.6vmin, 20px);
border-bottom: 1px solid rgba(198, 174, 122, 0.26);
animation: moonlit-song-current 0.68s ease-out both;
}
.theme-moonlit-water .song-current-mark {
width: 1.72em;
border: 0;
border-radius: 0;
color: #e7c982;
background: transparent;
box-shadow: none;
font-size: 1.42em;
position: relative;
width: clamp(46px, 3.7em, 68px);
border: 1px solid rgba(198, 174, 122, 0.62);
border-radius: 50%;
color: #d2b468;
background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%);
box-shadow:
inset 0 0 0 3px rgba(15, 57, 64, 0.2),
0 0 16px rgba(198, 174, 122, 0.08);
font-size: 1.3em;
}
.theme-moonlit-water .song-current-mark::after {
.theme-moonlit-water .song-current-mark::before {
content: '';
position: absolute;
top: -0.58em;
right: -0.18em;
width: 0.72em;
height: 0.42em;
border: 1px solid rgba(116, 166, 157, 0.52);
border-width: 1px 0 0 1px;
border-radius: 100% 0;
transform: rotate(-24deg);
}
.theme-moonlit-water .song-current-mark-label {
display: none;
}
.theme-moonlit-water .song-current-mark-note {
display: block;
width: 1.4em;
height: 1px;
margin-top: 0.1em;
background: linear-gradient(90deg, transparent, #e7c982, transparent);
line-height: 1;
}
.theme-moonlit-water .song-current-copy,
.theme-moonlit-water .song-score {
gap: 0.08em;
gap: 0.18em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
}
@@ -437,32 +468,89 @@ body,
.theme-moonlit-water .song-score small,
.theme-moonlit-water .song-requester,
.theme-moonlit-water .song-queue > header {
color: #afc2ad;
color: var(--component-song-requester-color, #a9b79b);
}
.theme-moonlit-water .song-current-copy strong {
color: #eadfc3;
font-size: 1.18em;
color: var(--component-song-title-color, #c6ae7a);
font-size: 1.52em;
font-weight: 600;
letter-spacing: 0.09em;
line-height: 1.16;
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
}
.theme-moonlit-water .song-score b,
.theme-moonlit-water .song-index {
color: #e7c982;
color: #c9b06f;
}
.theme-moonlit-water .song-score {
min-width: clamp(72px, 6.8em, 116px);
justify-items: end;
align-self: center;
}
.theme-moonlit-water .song-score b {
font-size: 0.68em;
font-weight: 500;
letter-spacing: 0.04em;
}
.theme-moonlit-water .song-score small {
display: none;
}
.theme-moonlit-water .song-waveform {
display: flex;
height: 0.8em;
align-items: end;
gap: 0.16em;
opacity: 0.34;
}
.theme-moonlit-water .song-waveform i {
width: 2px;
height: 25%;
background: #6a9f99;
animation: moonlit-song-wave 1.8s ease-in-out infinite alternate;
}
.theme-moonlit-water .song-waveform i:nth-child(2),
.theme-moonlit-water .song-waveform i:nth-child(7) {
height: 48%;
animation-delay: -0.4s;
}
.theme-moonlit-water .song-waveform i:nth-child(3),
.theme-moonlit-water .song-waveform i:nth-child(6) {
height: 76%;
animation-delay: -0.9s;
}
.theme-moonlit-water .song-waveform i:nth-child(4),
.theme-moonlit-water .song-waveform i:nth-child(5) {
height: 100%;
animation-delay: -1.25s;
}
.theme-moonlit-water .song-queue > header {
padding: 0.32em 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.24);
letter-spacing: 0.18em;
justify-content: flex-start;
gap: 0.6em;
padding: 0.28em 0 0.72em;
border-bottom: 0;
color: #a9b79b;
letter-spacing: 0.2em;
}
.theme-moonlit-water .song-queue > header::before {
content: '✦';
color: #6daaa3;
font-size: 0.66em;
}
.theme-moonlit-water .song-queue > header b {
padding: 0;
border-radius: 0;
color: #e7c982;
background: transparent;
display: none;
}
.theme-moonlit-water .song-queue-track {
@@ -471,10 +559,10 @@ body,
}
.theme-moonlit-water .song-row {
gap: 0.5em;
padding: 0.45em 0.12em;
grid-template-columns: 2.4em minmax(0, 1fr);
gap: 0.82em;
padding: 0.66em 1.3em 0.66em 0;
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.17);
border-radius: 0;
background: transparent;
box-shadow: none;
@@ -482,26 +570,50 @@ body,
animation-delay: var(--song-row-delay, 0ms);
}
.theme-moonlit-water .song-row::before,
.theme-moonlit-water .song-row::after {
width: 0.42em;
height: 0.42em;
background: #e7c982;
filter: drop-shadow(0 0 3px rgba(231, 201, 130, 0.5));
}
.theme-moonlit-water .song-row::after {
.theme-moonlit-water .song-row::before {
display: none;
}
.theme-moonlit-water .song-row::after {
content: '✦';
top: 50%;
right: 0.15em;
bottom: auto;
width: auto;
height: auto;
border-radius: 0;
color: #8eb5a4;
background: none;
filter: drop-shadow(0 0 3px rgba(142, 181, 164, 0.38));
font-size: 0.68em;
transform: translateY(-50%);
}
.theme-moonlit-water .song-row strong {
color: #eadfc3;
font-weight: 600;
color: var(--component-song-title-color, #c6ae7a);
font-weight: 500;
letter-spacing: 0.09em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.74);
}
.theme-moonlit-water .song-index {
font-size: 0.9em;
font-weight: 400;
letter-spacing: 0.08em;
}
.theme-moonlit-water .song-requester {
color: var(--component-song-requester-color, #a9b79b);
font-size: 0.78em;
letter-spacing: 0.06em;
}
.theme-moonlit-water .song-particle-layer {
opacity: 0.36;
opacity: 0.22;
}
.theme-moonlit-water .song-particle-layer > :nth-child(n + 5) {
display: none;
}
.theme-moonlit-water .song-particle.star {
@@ -518,6 +630,39 @@ body,
radial-gradient(circle at 50% 50%, #e7c982 0 18%, transparent 21%);
}
.song-overlay.theme-moonlit-water.song-narrow .song-current {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.song-overlay.theme-moonlit-water.song-narrow .song-score {
grid-column: auto;
grid-template-columns: 1fr;
min-width: 4.8em;
justify-items: end;
}
.song-overlay.theme-moonlit-water.song-short {
gap: 6px;
padding-block: 6px;
}
.song-overlay.theme-moonlit-water.song-short .song-current {
min-height: 58px;
padding-block: 3px 7px;
}
.song-overlay.theme-moonlit-water.song-short .song-current-mark {
width: 2.6em;
}
.song-overlay.theme-moonlit-water.song-short .song-current-copy strong {
font-size: 1.18em;
}
.song-overlay.theme-moonlit-water.song-short .song-row {
padding-block: 0.42em;
}
@keyframes moonlit-song-current {
from {
opacity: 0;
@@ -539,3 +684,20 @@ body,
transform: none;
}
}
@keyframes moonlit-song-wave {
from {
opacity: 0.32;
transform: scaleY(0.5);
}
to {
opacity: 0.88;
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.theme-moonlit-water .song-waveform i {
animation: none;
}
}
+29 -4
View File
@@ -174,7 +174,10 @@ function useSongQueue(preview: boolean, language: string, stream?: ComponentStre
const itemId = String(payload.itemId ?? '')
const item = normalizeSongRequestItem(payload.item)
const current = normalizeSongRequestItem(payload.current)
let queued = previous.queued.filter(entry => entry.id !== itemId && entry.id !== current?.id)
let queued =
operation === 'cleared'
? []
: previous.queued.filter(entry => entry.id !== itemId && entry.id !== current?.id)
if (item?.status === 'queued') queued.push(item)
if (operation === 'promoted' && item) {
queued = queued.map(entry =>
@@ -229,6 +232,9 @@ function useBounceScroll(
let direction = 1
let last = performance.now()
let pausedUntil = last + pauseSeconds * 1000
// OBS can ship an older Chromium that rounds scrollTop to whole pixels.
// Keep fractional movement here so low configured speeds still advance.
let pendingPixels = 0
const resize = new ResizeObserver(() => {
container.scrollTop = Math.min(
@@ -244,14 +250,21 @@ function useBounceScroll(
last = now
if (maximum === 0) container.scrollTop = 0
else if (now >= pausedUntil) {
container.scrollTop += direction * Math.max(5, speed) * (elapsed / 1000)
pendingPixels += Math.max(5, speed) * (elapsed / 1000)
const wholePixels = Math.floor(pendingPixels)
if (wholePixels > 0) {
container.scrollTop += direction * wholePixels
pendingPixels -= wholePixels
}
if (container.scrollTop >= maximum - 0.5) {
container.scrollTop = maximum
direction = -1
pendingPixels = 0
pausedUntil = now + pauseSeconds * 1000
} else if (container.scrollTop <= 0.5) {
container.scrollTop = 0
direction = 1
pendingPixels = 0
pausedUntil = now + pauseSeconds * 1000
}
}
@@ -309,8 +322,12 @@ export function SongRequestOverlay({
{
...themeCssVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-song-requester-color' as string]: settings.requesterColor || undefined,
['--component-song-title-color' as string]: settings.songTitleColor || undefined,
['--song-font-size' as string]: `${Math.min(22, Math.max(11, Math.min(bounds.width, bounds.height) * 0.024)) * (settings.fontScale / 100)}px`,
['--song-current-min' as string]: `${Math.min(72, Math.max(48, bounds.height * 0.08))}px`,
['--song-moonlit-font-size' as string]: `${Math.min(26, Math.max(14, Math.min(bounds.width * 0.045, bounds.height * 0.032))) * (settings.fontScale / 100)}px`,
['--song-moonlit-current-min' as string]: `${Math.min(118, Math.max(74, bounds.height * 0.135))}px`,
} as CSSProperties
}
>
@@ -321,7 +338,8 @@ export function SongRequestOverlay({
>
<SongParticles theme={theme} />
<div className="song-current-mark" aria-hidden="true">
{translate('song.overlay.sing_mark')}
<span className="song-current-mark-label">{translate('song.overlay.sing_mark')}</span>
<span className="song-current-mark-note">♫</span>
</div>
{queue.current ? (
<div className="song-current-copy">
@@ -339,6 +357,11 @@ export function SongRequestOverlay({
<div className="song-score">
<b>{score(queue.current)}</b>
<small>{translate('song.overlay.rate_help')}</small>
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</div>
</section>
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
@@ -355,8 +378,10 @@ export function SongRequestOverlay({
style={{ ['--song-row-delay' as string]: `${Math.min(index, 6) * 35}ms` }}
>
<span className="song-index">{String(index + 1).padStart(2, '0')}</span>
<strong>{item.title}</strong>
<div className="song-row-copy">
<span className="song-requester">{item.requester.name}</span>
<strong>{item.title}</strong>
</div>
</article>
))}
{queue.queued.length === 0 && (
+103
View File
@@ -0,0 +1,103 @@
/* Shared upper/lower ornament for the three text-led Moonlit Water widgets.
The SVG remains monochrome and transparent; this gradient owns its tint so
future themes can reuse the mask with a different palette. */
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
) {
--moonlit-edge-height: clamp(12px, 2.2vmin, 24px);
--moonlit-edge-offset: clamp(4px, 0.65vmin, 8px);
--moonlit-edge-space: clamp(10px, 1.8vmin, 20px);
position: relative;
isolation: isolate;
}
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::before,
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
content: '';
position: absolute;
z-index: 5;
left: clamp(8px, 1.4vmin, 18px);
right: clamp(8px, 1.4vmin, 18px);
height: var(--moonlit-edge-height);
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
rgba(138, 174, 158, 0.7) 13%,
rgba(211, 188, 127, 0.82) 39%,
#ead79f 50%,
rgba(211, 188, 127, 0.82) 61%,
rgba(138, 174, 158, 0.7) 87%,
transparent 100%
);
-webkit-mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
opacity: 0.72;
filter: drop-shadow(0 0 3px rgba(226, 204, 146, 0.2));
animation: moonlit-edge-breathe 7s ease-in-out infinite;
}
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::before {
top: var(--moonlit-edge-offset);
}
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
bottom: var(--moonlit-edge-offset);
transform: rotate(180deg);
animation-delay: -3.5s;
}
.overlay.theme-moonlit-water {
padding-block: calc(clamp(14px, 2.3vw, 34px) + var(--moonlit-edge-space));
}
.song-overlay.theme-moonlit-water {
padding-block: calc(clamp(10px, 1.8vmin, 22px) + var(--moonlit-edge-space));
}
.gift-menu-overlay.gift-menu-theme-moonlit-water {
padding-block: calc(clamp(8px, 1.3vmin, 16px) + var(--moonlit-edge-space));
}
@keyframes moonlit-edge-breathe {
0%,
100% {
opacity: 0.56;
}
50% {
opacity: 0.78;
}
}
@media (prefers-reduced-motion: reduce) {
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::before,
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
animation: none;
}
}
+1
View File
@@ -36,6 +36,7 @@ export function themeCssVariables(
'--theme-pattern-divider': `url("${theme.ornaments.patterns.divider}")`,
'--theme-pattern-cluster': `url("${theme.ornaments.patterns.cluster}")`,
'--theme-pattern-vine': `url("${theme.ornaments.patterns.vine}")`,
'--theme-pattern-edge': `url("${theme.ornaments.patterns.edge}")`,
'--theme-motion-arrive': theme.motion.arrive,
'--theme-motion-sheen': theme.motion.sheen,
'--theme-motion-featured': theme.motion.featured,
+2 -1
View File
@@ -3,6 +3,7 @@ import type { OverlayThemeDefinition } from './types'
const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
const edge = '/assets/moonlit-edge.svg'
/** The original ancient-style glass renderer, captured as the first theme. */
export const jadeScrollTheme: OverlayThemeDefinition = {
@@ -37,7 +38,7 @@ export const jadeScrollTheme: OverlayThemeDefinition = {
'star',
'floret',
],
patterns: { divider, cluster, vine },
patterns: { divider, cluster, vine, edge },
},
motion: {
arrive: 'arrive',
+5 -4
View File
@@ -3,6 +3,7 @@ import type { OverlayThemeDefinition } from './types'
const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
const edge = '/assets/moonlit-edge.svg'
/**
* A deliberately sparse, text-led theme inspired by moonlight on still water.
@@ -17,9 +18,9 @@ export const moonlitWaterTheme: OverlayThemeDefinition = {
descriptionKey: 'theme.moonlit_water.description',
className: 'theme-moonlit-water',
palette: {
text: '#eadfc3',
user: '#c7d5b2',
compactUser: '#a8beb0',
text: '#c6ae7a',
user: '#b7ad83',
compactUser: '#b7ad83',
accent: '#e7c982',
price: '#f2d79c',
},
@@ -30,7 +31,7 @@ export const moonlitWaterTheme: OverlayThemeDefinition = {
ornaments: {
variantCount: 4,
particles: ['star', 'floret', 'star', 'floret', 'star', 'star'],
patterns: { divider, cluster, vine },
patterns: { divider, cluster, vine, edge },
},
motion: {
arrive: 'moonlit-line-arrive',
+1
View File
@@ -34,6 +34,7 @@ export type OverlayThemeDefinition = {
divider: string
cluster: string
vine: string
edge: string
}
}
motion: {
+10
View File
@@ -12,6 +12,8 @@ export type OverlaySettings = {
themeId: OverlayThemeId
fontFamily: FontFamilyId
fontBrightness: number
viewerColor: string | null
danmakuColor: string | null
fontScale: number
showDanmaku: boolean
showEnter: boolean
@@ -35,6 +37,8 @@ export const defaultOverlaySettings: OverlaySettings = {
themeId: 'jade-scroll',
fontFamily: 'fang-song',
fontBrightness: 130,
viewerColor: null,
danmakuColor: null,
fontScale: 140,
showDanmaku: true,
showEnter: true,
@@ -59,6 +63,8 @@ export type SongRequestSettings = {
themeId: OverlayThemeId
fontFamily: FontFamilyId
fontBrightness: number
requesterColor: string | null
songTitleColor: string | null
fontScale: number
scrollSpeedPixelsPerSecond: number
edgePauseSeconds: number
@@ -72,6 +78,8 @@ export const defaultSongRequestSettings: SongRequestSettings = {
themeId: 'jade-scroll',
fontFamily: 'fang-song',
fontBrightness: 130,
requesterColor: null,
songTitleColor: null,
fontScale: 100,
scrollSpeedPixelsPerSecond: 28,
edgePauseSeconds: 2,
@@ -158,6 +166,7 @@ export type GiftMenuSettings = {
visibleRows: number
rowHeight: number
scrollSpeedPixelsPerSecond: number
pageIntervalMs: number
highlightDurationMs: number
fontScale: number
motionIntensity: number
@@ -172,6 +181,7 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
visibleRows: 4,
rowHeight: 88,
scrollSpeedPixelsPerSecond: 28,
pageIntervalMs: 6_000,
highlightDurationMs: 3_800,
fontScale: 100,
motionIntensity: 78,
+4
View File
@@ -130,6 +130,8 @@ struct OverlayFileConfig {
theme_id: Option<OverlayThemeId>,
font_family: Option<FontFamilyId>,
font_brightness: Option<u16>,
viewer_color: Option<String>,
danmaku_color: Option<String>,
font_scale: Option<u16>,
max_visible: Option<u8>,
collapse_after_seconds: Option<u16>,
@@ -328,6 +330,8 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
theme_id: file.theme_id.unwrap_or(default.theme_id),
font_family: file.font_family.unwrap_or(default.font_family),
font_brightness: file.font_brightness.unwrap_or(default.font_brightness),
viewer_color: file.viewer_color.or(default.viewer_color),
danmaku_color: file.danmaku_color.or(default.danmaku_color),
font_scale: file.font_scale.unwrap_or(default.font_scale),
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
show_enter: file.events.enter.unwrap_or(default.show_enter),
+23
View File
@@ -24,6 +24,10 @@ pub const GIFT_MENU_KIND: &str = "gift_menu";
pub const GIFT_MENU_NAME: &str = "礼物菜单";
const MAX_MENU_ITEMS: usize = 100;
const fn default_page_interval_ms() -> u16 {
6_000
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GiftMenuThemeId {
@@ -83,6 +87,10 @@ pub struct GiftMenuSettings {
pub visible_rows: u8,
pub row_height: u16,
pub scroll_speed_pixels_per_second: u16,
/// Delay between pages for non-scrolling themes. Kept separate from the
/// pixel speed so switching themes never destroys either preference.
#[serde(default = "default_page_interval_ms")]
pub page_interval_ms: u16,
pub highlight_duration_ms: u16,
pub font_scale: u16,
pub motion_intensity: u8,
@@ -99,6 +107,7 @@ impl Default for GiftMenuSettings {
visible_rows: 4,
row_height: 88,
scroll_speed_pixels_per_second: 28,
page_interval_ms: default_page_interval_ms(),
highlight_duration_ms: 3_800,
font_scale: 100,
motion_intensity: 78,
@@ -128,6 +137,7 @@ impl GiftMenuSettings {
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.row_height = self.row_height.clamp(44, 240);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240);
self.page_interval_ms = self.page_interval_ms.clamp(1_500, 30_000);
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);
@@ -372,6 +382,7 @@ mod tests {
visible_rows: 255,
row_height: 1,
scroll_speed_pixels_per_second: u16::MAX,
page_interval_ms: u16::MAX,
font_scale: 1,
font_brightness: 1,
..GiftMenuSettings::default()
@@ -382,10 +393,22 @@ mod tests {
assert_eq!(sanitized["visibleRows"], 20);
assert_eq!(sanitized["rowHeight"], 44);
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
assert_eq!(sanitized["pageIntervalMs"], 30_000);
assert_eq!(sanitized["fontScale"], 50);
assert_eq!(sanitized["fontBrightness"], 70);
}
#[test]
fn old_settings_receive_the_default_page_interval() {
let definition = GiftMenuDefinition;
let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap();
settings.as_object_mut().unwrap().remove("pageIntervalMs");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
}
#[test]
fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() {
let specific = Uuid::new_v4();
+23
View File
@@ -106,6 +106,10 @@ pub fn router(state: AppState) -> Router {
"/api/v1/components/{id}/song-requests",
get(list_song_requests),
)
.route(
"/api/v1/components/{id}/song-requests/clear",
post(clear_song_requests),
)
.route(
"/api/v1/components/{id}/song-requests/{request_id}/promote",
post(promote_song_request),
@@ -875,6 +879,25 @@ async fn cancel_song_request(
change_song_request(state, headers, id, request_id, true).await
}
async fn clear_song_requests(
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?;
require_song_component(&component)?;
let cancelled_count = state
.song_requests
.clear_active(&component, &session.user.room_id)
.await?;
Ok(Json(json!({
"ok": true,
"cancelledCount": cancelled_count,
})))
}
async fn change_song_request(
state: AppState,
headers: HeaderMap,
+26
View File
@@ -38,6 +38,12 @@ pub struct OverlaySettings {
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
/// Optional per-component overrides. `None` keeps the selected theme's
/// palette, which makes future theme changes immediately visible.
#[serde(default)]
pub viewer_color: Option<String>,
#[serde(default)]
pub danmaku_color: Option<String>,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
pub show_danmaku: bool,
@@ -68,6 +74,8 @@ impl Default for OverlaySettings {
theme_id: OverlayThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
viewer_color: None,
danmaku_color: None,
font_scale: default_font_scale(),
show_danmaku: true,
show_enter: true,
@@ -93,6 +101,8 @@ impl OverlaySettings {
pub fn sanitize(mut self) -> Self {
self.max_visible = self.max_visible.clamp(1, 12);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color);
self.font_scale = self.font_scale.clamp(50, 300);
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
@@ -106,6 +116,14 @@ impl OverlaySettings {
}
}
fn sanitize_optional_hex_color(value: Option<String>) -> Option<String> {
let value = value?.trim().to_ascii_uppercase();
(value.len() == 7
&& value.starts_with('#')
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
.then_some(value)
}
fn default_font_scale() -> u16 {
140
}
@@ -557,6 +575,8 @@ mod tests {
let settings = OverlaySettings {
font_scale: 999,
font_brightness: 1,
viewer_color: Some(" #a1b2c3 ".into()),
danmaku_color: Some("not-css".into()),
max_visible: 99,
collapse_after_seconds: 1,
unfold_duration_ms: 9_000,
@@ -570,6 +590,8 @@ mod tests {
.sanitize();
assert_eq!(settings.font_scale, 300);
assert_eq!(settings.font_brightness, 70);
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.max_visible, 12);
assert_eq!(settings.collapse_after_seconds, 2);
assert_eq!(settings.unfold_duration_ms, 5_000);
@@ -594,6 +616,8 @@ mod tests {
object.remove("fontScale");
object.remove("fontFamily");
object.remove("fontBrightness");
object.remove("viewerColor");
object.remove("danmakuColor");
object.remove("unfoldDurationMs");
object.remove("particleCount");
object.remove("particleSpeed");
@@ -602,6 +626,8 @@ mod tests {
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
assert_eq!(settings.font_family, FontFamilyId::FangSong);
assert_eq!(settings.font_brightness, 130);
assert_eq!(settings.viewer_color, None);
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.font_scale, 140);
assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8);
+69
View File
@@ -38,6 +38,12 @@ pub struct SongRequestSettings {
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
/// Optional text-color overrides. `None` follows the selected theme so a
/// later theme switch can update the palette without stale saved colors.
#[serde(default)]
pub requester_color: Option<String>,
#[serde(default)]
pub song_title_color: Option<String>,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
#[serde(default = "default_scroll_speed")]
@@ -61,6 +67,8 @@ impl Default for SongRequestSettings {
theme_id: OverlayThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
requester_color: None,
song_title_color: None,
font_scale: default_font_scale(),
scroll_speed_pixels_per_second: default_scroll_speed(),
edge_pause_seconds: default_edge_pause(),
@@ -75,6 +83,8 @@ impl SongRequestSettings {
pub fn sanitize(mut self) -> Self {
self.font_scale = self.font_scale.clamp(50, 250);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.requester_color = sanitize_optional_hex_color(self.requester_color);
self.song_title_color = sanitize_optional_hex_color(self.song_title_color);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.clamp(5, 200);
self.edge_pause_seconds = self.edge_pause_seconds.min(15);
self.max_queue_size = self.max_queue_size.min(10_000);
@@ -84,6 +94,14 @@ impl SongRequestSettings {
}
}
fn sanitize_optional_hex_color(value: Option<String>) -> Option<String> {
let value = value?.trim().to_ascii_uppercase();
(value.len() == 7
&& value.starts_with('#')
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
.then_some(value)
}
const fn default_font_scale() -> u16 {
100
}
@@ -465,6 +483,53 @@ impl SongRequestService {
)
}
/// Cancel every active request in one tenant-scoped transaction.
///
/// A single revision represents the whole reset. Publishing one reset
/// event also avoids exposing a half-cleared queue to OBS clients while a
/// long queue is being processed.
pub async fn clear_active(
&self,
component: &ComponentInstance,
room_id: &str,
) -> Result<u64, SongRequestError> {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, component.owner_id).await?;
lock_state(&transaction, component).await?;
let cancelled_count = transaction
.execute(
"UPDATE song_requests SET status='cancelled',queue_position=0,finished_at=now() \
WHERE owner_user_id=$1 AND component_instance_id=$2 \
AND status IN ('current','queued')",
&[&component.owner_id, &component.id],
)
.await?;
if cancelled_count == 0 {
transaction.commit().await?;
return Ok(0);
}
let revision = bump_revision(&transaction, component.id).await?;
transaction.commit().await?;
self.hub.publish(
component.id,
Arc::new(ComponentMessage::new(
component,
room_id,
"song.queue.changed",
json!({
"revision": revision,
"operation": "cleared",
"itemId": null,
"item": null,
"current": null,
"cancelledCount": cancelled_count,
}),
)),
);
Ok(cancelled_count)
}
async fn request_song(
&self,
component: &ComponentInstance,
@@ -1031,6 +1096,8 @@ mod tests {
let bounded = SongRequestSettings {
font_scale: 999,
font_brightness: u16::MAX,
requester_color: Some(" #a1b2c3 ".into()),
song_title_color: Some("transparent".into()),
scroll_speed_pixels_per_second: 0,
edge_pause_seconds: 200,
max_queue_size: u32::MAX,
@@ -1041,6 +1108,8 @@ mod tests {
.sanitize();
assert_eq!(bounded.font_scale, 250);
assert_eq!(bounded.font_brightness, 180);
assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3"));
assert_eq!(bounded.song_title_color, None);
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
assert_eq!(bounded.edge_pause_seconds, 15);
assert_eq!(bounded.max_queue_size, 10_000);
+3
View File
@@ -95,6 +95,9 @@ theme_id = "jade-scroll"
font_family = "fang-song"
# 文字亮度百分比,范围 70-180;深色直播背景建议从 130 开始。
font_brightness = 130
# 可选的弹幕昵称与正文颜色,只接受 #RRGGBB;不填写则跟随所选主题。
# viewer_color = "#B7AD83"
# danmaku_color = "#C6AE7A"
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
font_scale = 140
max_visible = 5
+4 -2
View File
@@ -17,6 +17,8 @@
| `themeId` | 主题 ID | 选择已安装的完整视觉主题 |
| `fontFamily` | 字体 ID | `song`、`fang-song`、`kai` |
| `fontBrightness` | 70–180% | 只调整文字亮度 |
| `viewerColor` | `#RRGGBB` | 可选的用户昵称颜色覆盖 |
| `danmakuColor` | `#RRGGBB` | 可选的弹幕正文颜色覆盖 |
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
@@ -30,13 +32,13 @@
| `show*` | boolean | 控制各事件类别订阅 |
后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range
input 只改善交互,不能取代服务端校验。字体 ID 只映射到前端预先注册的安全字体栈,不接受任意 CSS;OBS 设备缺少首选字体时会依次回退到可用衬线字体。
input 只改善交互,不能取代服务端校验。字体 ID 只映射到前端预先注册的安全字体栈,不接受任意 CSS;OBS 设备缺少首选字体时会依次回退到可用衬线字体。颜色覆盖同样只接受六位十六进制颜色;留空时使用主题色,切换主题后会自动采用新主题的配色。
## 主题
主题同时封装配色、花纹素材、装饰变体、粒子组合与动画名称。当前内置主题为
`jade-scroll`(“青玉花卷”)与
`moonlit-water`(“静夜曲水”)。后者不绘制卡片底色或外框,只保留青黛、米金衬线文字、细金分隔线与少量花星微光;控制台仍以选择框呈现,新增主题后无需改变设置交互或卡片队列。
`moonlit-water`(“静夜曲水”)。后者不绘制卡片底色、外框或消息分隔线,只保留青黛、米金衬线文字、少量花星微光以及组件上下共享的透明古风细边;控制台仍以选择框呈现,新增主题后无需改变设置交互或卡片队列。
增加主题时需要同步完成三处注册:
+4 -3
View File
@@ -25,10 +25,11 @@ schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电
## OBS 行为
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、滚动速度、高亮时长和动效强度均可调整。仅当内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、高亮时长和动效强度均可调整。`jade-banquet`
仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
命中时浏览器选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行、暂停自动滚动并播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子。舰长、提督和总督使用随前端打包的透明图标。
命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。
`jade-banquet`
复用仓库内已有且记录许可的花枝 SVG。`moonlit-water`(“静夜曲水”)提供另一种完全透明的排版:行项目没有卡片底色或外框,只以青黛、米金文字和细金线区分,触发时以水纹扫光高亮。新增主题必须在
复用仓库内已有且记录许可的花枝 SVG。`moonlit-water`(“静夜曲水”)提供另一种完全透明的分页排版,并与弹幕姬、点歌姬共享透明古风上下细边:每页行数和换页间隔可调,不进行纵向滚动;换页时旧内容从左向右渐隐,同时新内容沿相同方向淡入。礼物触发会以该条目为第一页首行,并以水纹扫光高亮。新增主题必须在
`giftMenuThemes.ts` 注册稳定 ID、资源键和局部 CSS 变量。
+4 -4
View File
@@ -13,15 +13,15 @@
## 设置
`themeId`、`fontFamily`、`fontBrightness`、`fontScale`、`scrollSpeedPixelsPerSecond` 和
`edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
`themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`scrollSpeedPixelsPerSecond`
和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲和待唱项会变为完全透明的衬线文字清单,以米金细线区分层级,不会在 OBS 上产生面板背景。
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲使用圆形音符、演唱状态、大号歌名、紧凑评分和弱化音频柱,待唱区按“点歌用户、歌名”两行显示。它不绘制卡片或面板背景,只用米金文字、青色微光、共享的上下古风细边和一条弱分隔线建立层级。用户和歌名颜色均可覆盖主题默认值。
## OBS 与管理
OBS 顶部固定显示当前歌曲、点歌用户、平均分和评分人数,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成或取消当前歌曲。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成、取消当前歌曲,或在确认后一次性将全部活动歌曲标记为已取消。清空操作保留历史与评分,并以一个 revision 原子广播。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
RLS 再次限制组件归属。
+3 -2
View File
@@ -102,8 +102,9 @@ close 结束连接。
3. `song.queue.snapshot.end`:提交该快照。
后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、
`completed`、`cancelled` 或
`rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。
`completed`、`cancelled`、`cleared` 或
`rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `cleared`
表示当前歌曲与全部待唱项已在同一事务中取消,客户端必须立即清空活动投影。
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
是供展示使用的人民币数值。
+29 -3
View File
@@ -147,6 +147,10 @@ name = "简体中文"
"settings.font_family.kai" = "楷体 · 书卷"
"settings.font_brightness" = "文字亮度"
"settings.font_brightness_description" = "只提亮文字,不改变透明背景、礼物图片或直播画面。"
"settings.viewer_color" = "用户昵称颜色"
"settings.danmaku_color" = "弹幕内容颜色"
"settings.use_theme_color" = "跟随主题"
"settings.text_color_description" = "选择后覆盖主题颜色;恢复后会随主题自动变化。"
"settings.font_size" = "字号"
"settings.max_visible" = "最大可见条数"
"settings.auto_collapse" = "自动收缩"
@@ -175,6 +179,8 @@ name = "简体中文"
"preview.horizontal" = "横向条"
"song.settings.scroll_speed" = "滚动速度"
"song.settings.edge_pause" = "两端暂停"
"song.settings.requester_color" = "点歌用户颜色"
"song.settings.title_color" = "歌名颜色"
"song.settings.limits" = "可选防刷限制(0 表示不限制)"
"song.settings.queue_limit" = "队列上限"
"song.settings.viewer_limit" = "每位观众上限"
@@ -285,6 +291,11 @@ name = "简体中文"
"song.completed" = "已完成并自动切换下一首。"
"song.cancelled" = "已取消点歌。"
"song.action_failed" = "队列操作失败"
"song.clear_queue" = "一键清空队列"
"song.clearing" = "正在清空…"
"song.clear_confirm" = "确定清空当前歌曲和全部待唱歌曲吗?它们会被标记为已取消并保留在历史记录中。"
"song.cleared" = "已清空点歌队列,共取消 {count} 首歌曲。"
"song.clear_failed" = "清空点歌队列失败"
"song.stats_title" = "点歌统计与队列"
"song.stats_eyebrow" = "点歌队列管理"
"song.back_settings" = "返回组件设置"
@@ -368,7 +379,7 @@ name = "简体中文"
"song.overlay.request_help" = "发送「点歌 歌名」加入队列"
"song.overlay.rate_help" = "发送「打分 1-5」"
"song.overlay.queue_aria" = "待唱队列"
"song.overlay.queue_title" = "待唱歌单"
"song.overlay.queue_title" = "待唱列表"
"song.overlay.queue_empty" = "下一首,会由谁来点呢?"
"theme.jade_scroll.name" = "青玉花卷"
"theme.jade_scroll.description" = "暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。"
@@ -445,8 +456,10 @@ name = "简体中文"
"gift_menu.guard_mark.governor" = "总"
"gift_menu.battery_mark" = "电"
"gift_menu.settings.visible_rows" = "最多展示行数"
"gift_menu.settings.rows_per_page" = "每页展示行数"
"gift_menu.settings.row_height" = "每行高度"
"gift_menu.settings.scroll_speed" = "循环滚动速度"
"gift_menu.settings.page_interval" = "换页间隔"
"gift_menu.settings.highlight_duration" = "触发高亮时长"
"gift_menu.settings.font_scale" = "文字缩放"
"gift_menu.settings.motion" = "动效强度"
@@ -454,7 +467,7 @@ name = "简体中文"
"gift_menu.unit.speed" = "{value} 像素/秒"
"gift_menu.unit.seconds" = "{value} 秒"
"gift_menu.preview.title" = "礼物菜单预览"
"gift_menu.preview.description" = "点击任意菜单行可预览定位、暂停滚动与渐变高亮动画。"
"gift_menu.preview.description" = "点击任意菜单行可预览定位与渐变高亮动画。"
"gift_menu.aria" = "直播间礼物菜单"
"gift_menu.overlay.battery" = "电池 ×{amount}"
"gift_menu.overlay.triggered_by" = "{viewer} 已触发"
@@ -613,6 +626,10 @@ name = "English"
"settings.font_family.kai" = "Kai · Calligraphic"
"settings.font_brightness" = "Text brightness"
"settings.font_brightness_description" = "Brightens text only; transparency, gift artwork, and the underlying scene are unchanged."
"settings.viewer_color" = "Viewer name color"
"settings.danmaku_color" = "Chat message color"
"settings.use_theme_color" = "Use theme color"
"settings.text_color_description" = "Overrides the theme when selected; reset to follow future theme changes."
"settings.font_size" = "Font size"
"settings.max_visible" = "Maximum visible items"
"settings.auto_collapse" = "Auto-collapse"
@@ -641,6 +658,8 @@ name = "English"
"preview.horizontal" = "Horizontal bar"
"song.settings.scroll_speed" = "Scroll speed"
"song.settings.edge_pause" = "Edge pause"
"song.settings.requester_color" = "Requester color"
"song.settings.title_color" = "Song title color"
"song.settings.limits" = "Optional anti-spam limits (0 means unlimited)"
"song.settings.queue_limit" = "Queue limit"
"song.settings.viewer_limit" = "Per-viewer limit"
@@ -751,6 +770,11 @@ name = "English"
"song.completed" = "Completed and advanced to the next song."
"song.cancelled" = "Song request cancelled."
"song.action_failed" = "Queue operation failed"
"song.clear_queue" = "Clear entire queue"
"song.clearing" = "Clearing…"
"song.clear_confirm" = "Clear the current song and every upcoming request? They will be marked cancelled and retained in history."
"song.cleared" = "Queue cleared. {count} requests were cancelled."
"song.clear_failed" = "Could not clear the song queue"
"song.stats_title" = "Song statistics and queue"
"song.stats_eyebrow" = "SONG QUEUE MANAGER"
"song.back_settings" = "Back to component settings"
@@ -911,8 +935,10 @@ name = "English"
"gift_menu.guard_mark.governor" = "V"
"gift_menu.battery_mark" = "B"
"gift_menu.settings.visible_rows" = "Maximum visible rows"
"gift_menu.settings.rows_per_page" = "Rows per page"
"gift_menu.settings.row_height" = "Row height"
"gift_menu.settings.scroll_speed" = "Loop scroll speed"
"gift_menu.settings.page_interval" = "Page interval"
"gift_menu.settings.highlight_duration" = "Trigger highlight duration"
"gift_menu.settings.font_scale" = "Text scale"
"gift_menu.settings.motion" = "Motion intensity"
@@ -920,7 +946,7 @@ name = "English"
"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.preview.description" = "Click any row to preview targeting 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}"