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, 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. 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>), ...(settings as Partial<OverlaySettings>),
themeId: normalizeThemeId(settings.themeId), themeId: normalizeThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily), 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 { export function normalizeSongRequestSettings(value: unknown): SongRequestSettings {
const root = object(value) const root = object(value)
const settings = object(root.settings ?? value) const settings = object(root.settings ?? value)
@@ -225,6 +233,8 @@ export function normalizeSongRequestSettings(value: unknown): SongRequestSetting
...(settings as Partial<SongRequestSettings>), ...(settings as Partial<SongRequestSettings>),
themeId: normalizeThemeId(settings.themeId), themeId: normalizeThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily), 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); 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 { .danmaku-content {
line-height: 1.35; line-height: 1.35;
} }
@@ -852,7 +857,7 @@ body,
} }
.card.danmaku.expanded .copy b { .card.danmaku.expanded .copy b {
color: var(--theme-user, #f0fffb); color: var(--component-viewer-color, var(--theme-user, #f0fffb));
font-size: 0.78em; font-size: 0.78em;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
@@ -884,7 +889,7 @@ body,
.card.danmaku.compact .copy b { .card.danmaku.compact .copy b {
max-width: none; max-width: none;
flex: 0 0 auto; 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 { .card.danmaku.compact .copy span {
@@ -1069,15 +1074,21 @@ body,
.theme-moonlit-water .card.danmaku .copy > b { .theme-moonlit-water .card.danmaku .copy > b {
grid-column: 2; grid-column: 2;
grid-row: 1; grid-row: 1;
color: var(--component-viewer-color, #b7ad83);
line-height: 1.25; line-height: 1.25;
} }
.theme-moonlit-water .card.danmaku .copy > .danmaku-content { .theme-moonlit-water .card.danmaku .copy > .danmaku-content {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 2; grid-row: 2;
color: var(--component-danmaku-color, #c6ae7a);
line-height: 1.35; 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 { .theme-moonlit-water .card.danmaku .viewer-avatar {
display: grid; display: grid;
grid-column: 1; grid-column: 1;
@@ -2030,6 +2041,36 @@ select {
margin-bottom: 24px; 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 { .typography-brightness > span {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -2291,6 +2332,7 @@ td:last-child {
.totp-enrollment, .totp-enrollment,
.slider-grid, .slider-grid,
.typography-settings, .typography-settings,
.danmaku-color-settings,
.two-columns { .two-columns {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+166 -29
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 { function isDanmakuKind(kind: string): boolean {
return kind === 'danmaku_overlay' || kind === 'danmaku' return kind === 'danmaku_overlay' || kind === 'danmaku'
} }
@@ -280,6 +324,22 @@ function SettingsEditor({
onFontFamily={value => edit('fontFamily', value)} onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', 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"> <div className="slider-grid">
<label> <label>
<span> <span>
@@ -467,6 +527,7 @@ function SongRequestSettingsEditor({
}) { }) {
const edit = <K extends keyof SongRequestSettings>(key: K, value: SongRequestSettings[K]) => const edit = <K extends keyof SongRequestSettings>(key: K, value: SongRequestSettings[K]) =>
onChange({ ...settings, [key]: value }) onChange({ ...settings, [key]: value })
const selectedTheme = getOverlayTheme(settings.themeId)
return ( return (
<div className="settings-editor"> <div className="settings-editor">
<div className="field-grid theme-selector"> <div className="field-grid theme-selector">
@@ -484,7 +545,7 @@ function SongRequestSettingsEditor({
</option> </option>
))} ))}
</select> </select>
<small>{translate(getOverlayTheme(settings.themeId).descriptionKey)}</small> <small>{translate(selectedTheme.descriptionKey)}</small>
</label> </label>
</div> </div>
<TypographySettingsFields <TypographySettingsFields
@@ -493,6 +554,22 @@ function SongRequestSettingsEditor({
onFontFamily={value => edit('fontFamily', value)} onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', 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"> <div className="slider-grid">
<label> <label>
<span> <span>
@@ -1121,7 +1198,12 @@ function GiftMenuSettingsEditor({
<div className="slider-grid gift-menu-renderer-settings"> <div className="slider-grid gift-menu-renderer-settings">
<label> <label>
<span> <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> </span>
<input <input
type="range" type="range"
@@ -1149,23 +1231,44 @@ function GiftMenuSettingsEditor({
onChange={event => edit('rowHeight', +event.target.value)} onChange={event => edit('rowHeight', +event.target.value)}
/> />
</label> </label>
<label> {theme.id === 'moonlit-water' ? (
<span> <label>
{translate('gift_menu.settings.scroll_speed')}{' '} <span>
<output> {translate('gift_menu.settings.page_interval')}{' '}
{translate('gift_menu.unit.speed', { <output>
value: settings.scrollSpeedPixelsPerSecond, {translate('gift_menu.unit.seconds', {
})} value: (settings.pageIntervalMs / 1000).toFixed(1),
</output> })}
</span> </output>
<input </span>
type="range" <input
min="0" type="range"
max="240" min="1500"
value={settings.scrollSpeedPixelsPerSecond} max="30000"
onChange={event => edit('scrollSpeedPixelsPerSecond', +event.target.value)} step="500"
/> value={settings.pageIntervalMs}
</label> onChange={event => edit('pageIntervalMs', +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> <label>
<span> <span>
{translate('gift_menu.settings.highlight_duration')}{' '} {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 const summary = active?.summary ?? historyPage?.summary
return ( return (
<ControlLayout user={user} active="components" onLogout={onLogout}> <ControlLayout user={user} active="components" onLogout={onLogout}>
@@ -2268,12 +2395,22 @@ export function SongRequestsPage({
<p className="eyebrow">{translate('song.stats_eyebrow')}</p> <p className="eyebrow">{translate('song.stats_eyebrow')}</p>
<h1>{translate('song.stats_title')}</h1> <h1>{translate('song.stats_title')}</h1>
</div> </div>
<a <div className="form-actions">
className="button-link secondary" <button
href={`/control/?component=${encodeURIComponent(componentId)}`} type="button"
> className="danger"
{translate('song.back_settings')} disabled={Boolean(busyId) || (!active?.current && (active?.items.length ?? 0) === 0)}
</a> onClick={() => void clearQueue()}
>
{busyId === 'clear' ? translate('song.clearing') : translate('song.clear_queue')}
</button>
<a
className="button-link secondary"
href={`/control/?component=${encodeURIComponent(componentId)}`}
>
{translate('song.back_settings')}
</a>
</div>
</div> </div>
<FlashMessage flash={flash} /> <FlashMessage flash={flash} />
<div className="song-stat-grid"> <div className="song-stat-grid">
@@ -2312,7 +2449,7 @@ export function SongRequestsPage({
<div className="form-actions"> <div className="form-actions">
<button <button
type="button" type="button"
disabled={busyId === active.current.id} disabled={Boolean(busyId)}
onClick={() => void mutate(active.current!, 'complete')} onClick={() => void mutate(active.current!, 'complete')}
> >
{translate('song.complete_current')} {translate('song.complete_current')}
@@ -2320,7 +2457,7 @@ export function SongRequestsPage({
<button <button
type="button" type="button"
className="danger" className="danger"
disabled={busyId === active.current.id} disabled={Boolean(busyId)}
onClick={() => void mutate(active.current!, 'cancel')} onClick={() => void mutate(active.current!, 'cancel')}
> >
{translate('common.cancel')} {translate('common.cancel')}
@@ -2351,7 +2488,7 @@ export function SongRequestsPage({
<button <button
type="button" type="button"
className="secondary small" className="secondary small"
disabled={busyId === item.id || index === 0} disabled={Boolean(busyId) || index === 0}
onClick={() => void mutate(item, 'promote')} onClick={() => void mutate(item, 'promote')}
> >
{translate('song.promote')} {translate('song.promote')}
@@ -2359,7 +2496,7 @@ export function SongRequestsPage({
<button <button
type="button" type="button"
className="danger small" className="danger small"
disabled={busyId === item.id} disabled={Boolean(busyId)}
onClick={() => void mutate(item, 'cancel')} onClick={() => void mutate(item, 'cancel')}
> >
{translate('common.cancel')} {translate('common.cancel')}
+77 -2
View File
@@ -49,6 +49,16 @@ body,
width: 100%; width: 100%;
} }
.gift-menu-page-stack {
height: 100%;
}
.gift-menu-page {
position: absolute;
inset: 0 0 auto;
width: 100%;
}
.gift-menu-row { .gift-menu-row {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
@@ -406,14 +416,22 @@ body,
.gift-menu-theme-moonlit-water .gift-menu-viewport { .gift-menu-theme-moonlit-water .gift-menu-viewport {
height: min(100%, var(--menu-panel-height)); height: min(100%, var(--menu-panel-height));
border: 0; 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; border-radius: 0;
outline: 0; outline: 0;
background: transparent; background: transparent;
box-shadow: none; 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,
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n + 2), .gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n + 2),
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n) { .gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n) {
@@ -528,3 +546,60 @@ body,
filter: brightness(1); 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. */ /** Transparent gift-to-content menu with theme-selectable motion for OBS. */
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import { normalizeGiftMenuSettings } from './api' import { normalizeGiftMenuSettings } from './api'
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemeVariables } from './giftMenuThemes' import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemeVariables } from './giftMenuThemes'
@@ -25,6 +25,12 @@ type ActiveTrigger = {
viewer: string viewer: string
} }
type PageTransition = {
currentStart: number
previousStart?: number
nonce: number
}
function triggerLabel(item: GiftMenuItem): string { function triggerLabel(item: GiftMenuItem): string {
if (item.trigger.kind === 'gift') return item.trigger.giftName if (item.trigger.kind === 'gift') return item.trigger.giftName
if (item.trigger.kind === 'guard') return translate(`gift_menu.guard.${item.trigger.level}`) 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 scrollRemainder = useRef(0)
const [loop, setLoop] = useState(false) const [loop, setLoop] = useState(false)
const theme = getGiftMenuTheme(settings.themeId) 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 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(() => { useEffect(() => {
if (!preview || !previewTriggeredItemId) return if (!preview || !previewTriggeredItemId) return
@@ -181,16 +211,21 @@ export function GiftMenuOverlay({
useEffect(() => { useEffect(() => {
const node = viewport.current const node = viewport.current
if (!node) return if (!node) return
if (paged) {
node.scrollTop = 0
setLoop(false)
return
}
const update = () => setLoop(cycleHeight > node.clientHeight + 1) const update = () => setLoop(cycleHeight > node.clientHeight + 1)
update() update()
const observer = new ResizeObserver(update) const observer = new ResizeObserver(update)
observer.observe(node) observer.observe(node)
return () => observer.disconnect() return () => observer.disconnect()
}, [cycleHeight]) }, [cycleHeight, paged])
useEffect(() => { useEffect(() => {
const node = viewport.current 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) if (node.scrollTop < cycleHeight || node.scrollTop >= cycleHeight * 2)
node.scrollTop = cycleHeight + (node.scrollTop % cycleHeight) node.scrollTop = cycleHeight + (node.scrollTop % cycleHeight)
scrollRemainder.current = 0 scrollRemainder.current = 0
@@ -218,7 +253,52 @@ export function GiftMenuOverlay({
cancelAnimationFrame(frame) cancelAnimationFrame(frame)
scrollRemainder.current = 0 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(() => { useEffect(() => {
const node = viewport.current const node = viewport.current
@@ -226,6 +306,10 @@ export function GiftMenuOverlay({
if (!node || !id) return if (!node || !id) return
const index = settings.items.findIndex(item => item.id === id) const index = settings.items.findIndex(item => item.id === id)
if (index < 0) return if (index < 0) return
if (paged) {
showPage(index)
return
}
scrollRemainder.current = 0 scrollRemainder.current = 0
if (!loop || cycleHeight <= 0) { if (!loop || cycleHeight <= 0) {
node.scrollTo({ node.scrollTo({
@@ -245,9 +329,11 @@ export function GiftMenuOverlay({
cycleHeight, cycleHeight,
effectiveActive?.nonce, effectiveActive?.nonce,
loop, loop,
paged,
settings.highlightDurationMs, settings.highlightDurationMs,
settings.items, settings.items,
settings.rowHeight, settings.rowHeight,
showPage,
]) ])
useEffect(() => { useEffect(() => {
@@ -260,6 +346,18 @@ export function GiftMenuOverlay({
}, [effectiveActive?.nonce, preview, settings.highlightDurationMs]) }, [effectiveActive?.nonce, preview, settings.highlightDurationMs])
const copies = useMemo(() => (loop ? [0, 1, 2] : [0]), [loop]) 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 ( return (
<main <main
className={`gift-menu-overlay ${theme.className} ${preview ? 'gift-menu-preview' : ''} ${settings.lowPerformanceMode ? 'gift-menu-low-motion' : ''}`} 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-empty-size' as string]: `${18 * (settings.fontScale / 100)}px`,
['--menu-highlight-duration' as string]: `${settings.highlightDurationMs}ms`, ['--menu-highlight-duration' as string]: `${settings.highlightDurationMs}ms`,
['--menu-particle-duration' as string]: `${settings.highlightDurationMs * 0.72}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, ['--menu-motion' as string]: settings.motionIntensity / 100,
} as CSSProperties } as CSSProperties
} }
@@ -287,6 +386,23 @@ export function GiftMenuOverlay({
> >
{settings.items.length === 0 ? ( {settings.items.length === 0 ? (
<div className="gift-menu-empty">{translate('gift_menu.overlay.empty')}</div> <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"> <div className="gift-menu-track">
{copies.flatMap(copy => {copies.flatMap(copy =>
+4 -1
View File
@@ -13,7 +13,7 @@ export type GiftMenuTheme = {
rose: string rose: string
ink: string ink: string
} }
ornaments: { divider: string; cluster: string; vine: string } ornaments: { divider: string; cluster: string; vine: string; edge: string }
} }
export const giftMenuThemes: readonly GiftMenuTheme[] = [ export const giftMenuThemes: readonly GiftMenuTheme[] = [
@@ -33,6 +33,7 @@ export const giftMenuThemes: readonly GiftMenuTheme[] = [
divider: '/assets/floral-divider.svg', divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg', cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.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', divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg', cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.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-divider' as string]: `url(${theme.ornaments.divider})`,
['--menu-cluster' as string]: `url(${theme.ornaments.cluster})`, ['--menu-cluster' as string]: `url(${theme.ornaments.cluster})`,
['--menu-vine' as string]: `url(${theme.ornaments.vine})`, ['--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 './giftEffect.css'
import './giftMenu.css' import './giftMenu.css'
import './song.css' import './song.css'
import './themeEdges.css'
function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) { function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) {
const stream = useComponentStream(false, publicId, accessToken) const stream = useComponentStream(false, publicId, accessToken)
+2
View File
@@ -411,6 +411,8 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
{ {
...themeCssVariables(theme), ...themeCssVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness), ...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}`, ['--motion' as string]: `${settings.motionIntensity / 100}`,
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`, ['--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`, ['--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; font-size: 1.25em;
} }
.song-current-mark-note,
.song-waveform {
display: none;
}
.song-current-copy, .song-current-copy,
.song-score { .song-score {
position: relative; position: relative;
@@ -87,11 +92,12 @@ body,
.song-current-copy small, .song-current-copy small,
.song-score small { .song-score small {
color: var(--theme-compact-user); color: var(--component-song-requester-color, var(--theme-compact-user));
font-size: 0.68em; font-size: 0.68em;
} }
.song-current-copy strong { .song-current-copy strong {
color: var(--component-song-title-color, var(--theme-text));
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-size: 1.35em; font-size: 1.35em;
line-height: 1.08; line-height: 1.08;
@@ -151,7 +157,7 @@ body,
.song-row { .song-row {
display: grid; display: grid;
min-width: 0; 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; align-items: center;
gap: 0.55em; gap: 0.55em;
padding: 0.4em 0.62em; padding: 0.4em 0.62em;
@@ -203,8 +209,15 @@ body,
animation-delay: -1.7s; animation-delay: -1.7s;
} }
.song-row-copy {
display: grid;
min-width: 0;
gap: 0.12em;
}
.song-row strong { .song-row strong {
min-width: 0; min-width: 0;
color: var(--component-song-title-color, var(--theme-text));
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -216,8 +229,8 @@ body,
.song-requester { .song-requester {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
color: var(--theme-compact-user); color: var(--component-song-requester-color, var(--theme-compact-user));
text-align: right; text-align: left;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -376,13 +389,15 @@ body,
} }
} }
/* Moonlit Water keeps the queue as a quiet typeset list: no panel fill, no /* Moonlit Water follows the reference's restrained broadcast typography: a
glass, and just enough gold rule-work to remain legible over an OBS scene. */ compact current-song lockup above a borderless three-column programme. */
.song-overlay.theme-moonlit-water { .song-overlay.theme-moonlit-water {
gap: clamp(5px, 0.7vmin, 9px); gap: clamp(12px, 2vmin, 24px);
padding: clamp(8px, 1.4vmin, 16px); padding: clamp(10px, 1.8vmin, 22px);
color: #eadfc3; color: #c6ae7a;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif); 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, .theme-moonlit-water .song-current,
@@ -400,36 +415,52 @@ body,
} }
.theme-moonlit-water .song-current { .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; grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(5px, 0.9vmin, 10px); gap: clamp(10px, 1.5vmin, 18px);
padding: 0 0 clamp(6px, 0.9vmin, 10px); padding: clamp(6px, 0.8vmin, 10px) 0 clamp(12px, 1.6vmin, 20px);
border-bottom: 1px solid rgba(231, 201, 130, 0.38); border-bottom: 1px solid rgba(198, 174, 122, 0.26);
animation: moonlit-song-current 0.68s ease-out both; animation: moonlit-song-current 0.68s ease-out both;
} }
.theme-moonlit-water .song-current-mark { .theme-moonlit-water .song-current-mark {
width: 1.72em; position: relative;
border: 0; width: clamp(46px, 3.7em, 68px);
border-radius: 0; border: 1px solid rgba(198, 174, 122, 0.62);
color: #e7c982; border-radius: 50%;
background: transparent; color: #d2b468;
box-shadow: none; background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%);
font-size: 1.42em; 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: ''; 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; display: block;
width: 1.4em; line-height: 1;
height: 1px;
margin-top: 0.1em;
background: linear-gradient(90deg, transparent, #e7c982, transparent);
} }
.theme-moonlit-water .song-current-copy, .theme-moonlit-water .song-current-copy,
.theme-moonlit-water .song-score { .theme-moonlit-water .song-score {
gap: 0.08em; gap: 0.18em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82); 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-score small,
.theme-moonlit-water .song-requester, .theme-moonlit-water .song-requester,
.theme-moonlit-water .song-queue > header { .theme-moonlit-water .song-queue > header {
color: #afc2ad; color: var(--component-song-requester-color, #a9b79b);
} }
.theme-moonlit-water .song-current-copy strong { .theme-moonlit-water .song-current-copy strong {
color: #eadfc3; color: var(--component-song-title-color, #c6ae7a);
font-size: 1.18em; font-size: 1.52em;
font-weight: 600; font-weight: 600;
letter-spacing: 0.09em;
line-height: 1.16;
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8); text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
} }
.theme-moonlit-water .song-score b, .theme-moonlit-water .song-score b,
.theme-moonlit-water .song-index { .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 { .theme-moonlit-water .song-queue > header {
padding: 0.32em 0; justify-content: flex-start;
border-bottom: 1px solid rgba(231, 201, 130, 0.24); gap: 0.6em;
letter-spacing: 0.18em; 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 { .theme-moonlit-water .song-queue > header b {
padding: 0; display: none;
border-radius: 0;
color: #e7c982;
background: transparent;
} }
.theme-moonlit-water .song-queue-track { .theme-moonlit-water .song-queue-track {
@@ -471,10 +559,10 @@ body,
} }
.theme-moonlit-water .song-row { .theme-moonlit-water .song-row {
gap: 0.5em; grid-template-columns: 2.4em minmax(0, 1fr);
padding: 0.45em 0.12em; gap: 0.82em;
padding: 0.66em 1.3em 0.66em 0;
border: 0; border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.17);
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
@@ -482,26 +570,50 @@ body,
animation-delay: var(--song-row-delay, 0ms); animation-delay: var(--song-row-delay, 0ms);
} }
.theme-moonlit-water .song-row::before, .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 {
display: none; 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 { .theme-moonlit-water .song-row strong {
color: #eadfc3; color: var(--component-song-title-color, #c6ae7a);
font-weight: 600; font-weight: 500;
letter-spacing: 0.09em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.74); 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 { .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 { .theme-moonlit-water .song-particle.star {
@@ -518,6 +630,39 @@ body,
radial-gradient(circle at 50% 50%, #e7c982 0 18%, transparent 21%); 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 { @keyframes moonlit-song-current {
from { from {
opacity: 0; opacity: 0;
@@ -539,3 +684,20 @@ body,
transform: none; 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;
}
}
+30 -5
View File
@@ -174,7 +174,10 @@ function useSongQueue(preview: boolean, language: string, stream?: ComponentStre
const itemId = String(payload.itemId ?? '') const itemId = String(payload.itemId ?? '')
const item = normalizeSongRequestItem(payload.item) const item = normalizeSongRequestItem(payload.item)
const current = normalizeSongRequestItem(payload.current) 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 (item?.status === 'queued') queued.push(item)
if (operation === 'promoted' && item) { if (operation === 'promoted' && item) {
queued = queued.map(entry => queued = queued.map(entry =>
@@ -229,6 +232,9 @@ function useBounceScroll(
let direction = 1 let direction = 1
let last = performance.now() let last = performance.now()
let pausedUntil = last + pauseSeconds * 1000 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(() => { const resize = new ResizeObserver(() => {
container.scrollTop = Math.min( container.scrollTop = Math.min(
@@ -244,14 +250,21 @@ function useBounceScroll(
last = now last = now
if (maximum === 0) container.scrollTop = 0 if (maximum === 0) container.scrollTop = 0
else if (now >= pausedUntil) { 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) { if (container.scrollTop >= maximum - 0.5) {
container.scrollTop = maximum container.scrollTop = maximum
direction = -1 direction = -1
pendingPixels = 0
pausedUntil = now + pauseSeconds * 1000 pausedUntil = now + pauseSeconds * 1000
} else if (container.scrollTop <= 0.5) { } else if (container.scrollTop <= 0.5) {
container.scrollTop = 0 container.scrollTop = 0
direction = 1 direction = 1
pendingPixels = 0
pausedUntil = now + pauseSeconds * 1000 pausedUntil = now + pauseSeconds * 1000
} }
} }
@@ -309,8 +322,12 @@ export function SongRequestOverlay({
{ {
...themeCssVariables(theme), ...themeCssVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness), ...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-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-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 } as CSSProperties
} }
> >
@@ -321,7 +338,8 @@ export function SongRequestOverlay({
> >
<SongParticles theme={theme} /> <SongParticles theme={theme} />
<div className="song-current-mark" aria-hidden="true"> <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> </div>
{queue.current ? ( {queue.current ? (
<div className="song-current-copy"> <div className="song-current-copy">
@@ -339,6 +357,11 @@ export function SongRequestOverlay({
<div className="song-score"> <div className="song-score">
<b>{score(queue.current)}</b> <b>{score(queue.current)}</b>
<small>{translate('song.overlay.rate_help')}</small> <small>{translate('song.overlay.rate_help')}</small>
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</div> </div>
</section> </section>
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}> <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` }} style={{ ['--song-row-delay' as string]: `${Math.min(index, 6) * 35}ms` }}
> >
<span className="song-index">{String(index + 1).padStart(2, '0')}</span> <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> <span className="song-requester">{item.requester.name}</span>
<strong>{item.title}</strong>
</div>
</article> </article>
))} ))}
{queue.queued.length === 0 && ( {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-divider': `url("${theme.ornaments.patterns.divider}")`,
'--theme-pattern-cluster': `url("${theme.ornaments.patterns.cluster}")`, '--theme-pattern-cluster': `url("${theme.ornaments.patterns.cluster}")`,
'--theme-pattern-vine': `url("${theme.ornaments.patterns.vine}")`, '--theme-pattern-vine': `url("${theme.ornaments.patterns.vine}")`,
'--theme-pattern-edge': `url("${theme.ornaments.patterns.edge}")`,
'--theme-motion-arrive': theme.motion.arrive, '--theme-motion-arrive': theme.motion.arrive,
'--theme-motion-sheen': theme.motion.sheen, '--theme-motion-sheen': theme.motion.sheen,
'--theme-motion-featured': theme.motion.featured, '--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 divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg' const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg' const vine = '/assets/floral-vine.svg'
const edge = '/assets/moonlit-edge.svg'
/** The original ancient-style glass renderer, captured as the first theme. */ /** The original ancient-style glass renderer, captured as the first theme. */
export const jadeScrollTheme: OverlayThemeDefinition = { export const jadeScrollTheme: OverlayThemeDefinition = {
@@ -37,7 +38,7 @@ export const jadeScrollTheme: OverlayThemeDefinition = {
'star', 'star',
'floret', 'floret',
], ],
patterns: { divider, cluster, vine }, patterns: { divider, cluster, vine, edge },
}, },
motion: { motion: {
arrive: 'arrive', arrive: 'arrive',
+5 -4
View File
@@ -3,6 +3,7 @@ import type { OverlayThemeDefinition } from './types'
const divider = '/assets/floral-divider.svg' const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg' const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.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. * 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', descriptionKey: 'theme.moonlit_water.description',
className: 'theme-moonlit-water', className: 'theme-moonlit-water',
palette: { palette: {
text: '#eadfc3', text: '#c6ae7a',
user: '#c7d5b2', user: '#b7ad83',
compactUser: '#a8beb0', compactUser: '#b7ad83',
accent: '#e7c982', accent: '#e7c982',
price: '#f2d79c', price: '#f2d79c',
}, },
@@ -30,7 +31,7 @@ export const moonlitWaterTheme: OverlayThemeDefinition = {
ornaments: { ornaments: {
variantCount: 4, variantCount: 4,
particles: ['star', 'floret', 'star', 'floret', 'star', 'star'], particles: ['star', 'floret', 'star', 'floret', 'star', 'star'],
patterns: { divider, cluster, vine }, patterns: { divider, cluster, vine, edge },
}, },
motion: { motion: {
arrive: 'moonlit-line-arrive', arrive: 'moonlit-line-arrive',
+1
View File
@@ -34,6 +34,7 @@ export type OverlayThemeDefinition = {
divider: string divider: string
cluster: string cluster: string
vine: string vine: string
edge: string
} }
} }
motion: { motion: {
+10
View File
@@ -12,6 +12,8 @@ export type OverlaySettings = {
themeId: OverlayThemeId themeId: OverlayThemeId
fontFamily: FontFamilyId fontFamily: FontFamilyId
fontBrightness: number fontBrightness: number
viewerColor: string | null
danmakuColor: string | null
fontScale: number fontScale: number
showDanmaku: boolean showDanmaku: boolean
showEnter: boolean showEnter: boolean
@@ -35,6 +37,8 @@ export const defaultOverlaySettings: OverlaySettings = {
themeId: 'jade-scroll', themeId: 'jade-scroll',
fontFamily: 'fang-song', fontFamily: 'fang-song',
fontBrightness: 130, fontBrightness: 130,
viewerColor: null,
danmakuColor: null,
fontScale: 140, fontScale: 140,
showDanmaku: true, showDanmaku: true,
showEnter: true, showEnter: true,
@@ -59,6 +63,8 @@ export type SongRequestSettings = {
themeId: OverlayThemeId themeId: OverlayThemeId
fontFamily: FontFamilyId fontFamily: FontFamilyId
fontBrightness: number fontBrightness: number
requesterColor: string | null
songTitleColor: string | null
fontScale: number fontScale: number
scrollSpeedPixelsPerSecond: number scrollSpeedPixelsPerSecond: number
edgePauseSeconds: number edgePauseSeconds: number
@@ -72,6 +78,8 @@ export const defaultSongRequestSettings: SongRequestSettings = {
themeId: 'jade-scroll', themeId: 'jade-scroll',
fontFamily: 'fang-song', fontFamily: 'fang-song',
fontBrightness: 130, fontBrightness: 130,
requesterColor: null,
songTitleColor: null,
fontScale: 100, fontScale: 100,
scrollSpeedPixelsPerSecond: 28, scrollSpeedPixelsPerSecond: 28,
edgePauseSeconds: 2, edgePauseSeconds: 2,
@@ -158,6 +166,7 @@ export type GiftMenuSettings = {
visibleRows: number visibleRows: number
rowHeight: number rowHeight: number
scrollSpeedPixelsPerSecond: number scrollSpeedPixelsPerSecond: number
pageIntervalMs: number
highlightDurationMs: number highlightDurationMs: number
fontScale: number fontScale: number
motionIntensity: number motionIntensity: number
@@ -172,6 +181,7 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
visibleRows: 4, visibleRows: 4,
rowHeight: 88, rowHeight: 88,
scrollSpeedPixelsPerSecond: 28, scrollSpeedPixelsPerSecond: 28,
pageIntervalMs: 6_000,
highlightDurationMs: 3_800, highlightDurationMs: 3_800,
fontScale: 100, fontScale: 100,
motionIntensity: 78, motionIntensity: 78,
+4
View File
@@ -130,6 +130,8 @@ struct OverlayFileConfig {
theme_id: Option<OverlayThemeId>, theme_id: Option<OverlayThemeId>,
font_family: Option<FontFamilyId>, font_family: Option<FontFamilyId>,
font_brightness: Option<u16>, font_brightness: Option<u16>,
viewer_color: Option<String>,
danmaku_color: Option<String>,
font_scale: Option<u16>, font_scale: Option<u16>,
max_visible: Option<u8>, max_visible: Option<u8>,
collapse_after_seconds: Option<u16>, 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), theme_id: file.theme_id.unwrap_or(default.theme_id),
font_family: file.font_family.unwrap_or(default.font_family), font_family: file.font_family.unwrap_or(default.font_family),
font_brightness: file.font_brightness.unwrap_or(default.font_brightness), 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), font_scale: file.font_scale.unwrap_or(default.font_scale),
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku), show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
show_enter: file.events.enter.unwrap_or(default.show_enter), 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 = "礼物菜单"; pub const GIFT_MENU_NAME: &str = "礼物菜单";
const MAX_MENU_ITEMS: usize = 100; const MAX_MENU_ITEMS: usize = 100;
const fn default_page_interval_ms() -> u16 {
6_000
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum GiftMenuThemeId { pub enum GiftMenuThemeId {
@@ -83,6 +87,10 @@ pub struct GiftMenuSettings {
pub visible_rows: u8, pub visible_rows: u8,
pub row_height: u16, pub row_height: u16,
pub scroll_speed_pixels_per_second: 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 highlight_duration_ms: u16,
pub font_scale: u16, pub font_scale: u16,
pub motion_intensity: u8, pub motion_intensity: u8,
@@ -99,6 +107,7 @@ impl Default for GiftMenuSettings {
visible_rows: 4, visible_rows: 4,
row_height: 88, row_height: 88,
scroll_speed_pixels_per_second: 28, scroll_speed_pixels_per_second: 28,
page_interval_ms: default_page_interval_ms(),
highlight_duration_ms: 3_800, highlight_duration_ms: 3_800,
font_scale: 100, font_scale: 100,
motion_intensity: 78, motion_intensity: 78,
@@ -128,6 +137,7 @@ impl GiftMenuSettings {
self.font_brightness = sanitize_font_brightness(self.font_brightness); self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.row_height = self.row_height.clamp(44, 240); self.row_height = self.row_height.clamp(44, 240);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(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.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
self.font_scale = self.font_scale.clamp(50, 220); self.font_scale = self.font_scale.clamp(50, 220);
self.motion_intensity = self.motion_intensity.min(100); self.motion_intensity = self.motion_intensity.min(100);
@@ -372,6 +382,7 @@ mod tests {
visible_rows: 255, visible_rows: 255,
row_height: 1, row_height: 1,
scroll_speed_pixels_per_second: u16::MAX, scroll_speed_pixels_per_second: u16::MAX,
page_interval_ms: u16::MAX,
font_scale: 1, font_scale: 1,
font_brightness: 1, font_brightness: 1,
..GiftMenuSettings::default() ..GiftMenuSettings::default()
@@ -382,10 +393,22 @@ mod tests {
assert_eq!(sanitized["visibleRows"], 20); assert_eq!(sanitized["visibleRows"], 20);
assert_eq!(sanitized["rowHeight"], 44); assert_eq!(sanitized["rowHeight"], 44);
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240); assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
assert_eq!(sanitized["pageIntervalMs"], 30_000);
assert_eq!(sanitized["fontScale"], 50); assert_eq!(sanitized["fontScale"], 50);
assert_eq!(sanitized["fontBrightness"], 70); 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] #[test]
fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() { fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() {
let specific = Uuid::new_v4(); let specific = Uuid::new_v4();
+23
View File
@@ -106,6 +106,10 @@ pub fn router(state: AppState) -> Router {
"/api/v1/components/{id}/song-requests", "/api/v1/components/{id}/song-requests",
get(list_song_requests), get(list_song_requests),
) )
.route(
"/api/v1/components/{id}/song-requests/clear",
post(clear_song_requests),
)
.route( .route(
"/api/v1/components/{id}/song-requests/{request_id}/promote", "/api/v1/components/{id}/song-requests/{request_id}/promote",
post(promote_song_request), post(promote_song_request),
@@ -875,6 +879,25 @@ async fn cancel_song_request(
change_song_request(state, headers, id, request_id, true).await 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( async fn change_song_request(
state: AppState, state: AppState,
headers: HeaderMap, headers: HeaderMap,
+26
View File
@@ -38,6 +38,12 @@ pub struct OverlaySettings {
pub font_family: FontFamilyId, pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")] #[serde(default = "default_font_brightness")]
pub font_brightness: u16, 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")] #[serde(default = "default_font_scale")]
pub font_scale: u16, pub font_scale: u16,
pub show_danmaku: bool, pub show_danmaku: bool,
@@ -68,6 +74,8 @@ impl Default for OverlaySettings {
theme_id: OverlayThemeId::default(), theme_id: OverlayThemeId::default(),
font_family: FontFamilyId::default(), font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(), font_brightness: default_font_brightness(),
viewer_color: None,
danmaku_color: None,
font_scale: default_font_scale(), font_scale: default_font_scale(),
show_danmaku: true, show_danmaku: true,
show_enter: true, show_enter: true,
@@ -93,6 +101,8 @@ impl OverlaySettings {
pub fn sanitize(mut self) -> Self { pub fn sanitize(mut self) -> Self {
self.max_visible = self.max_visible.clamp(1, 12); self.max_visible = self.max_visible.clamp(1, 12);
self.font_brightness = sanitize_font_brightness(self.font_brightness); 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.font_scale = self.font_scale.clamp(50, 300);
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120); self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000); 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 { fn default_font_scale() -> u16 {
140 140
} }
@@ -557,6 +575,8 @@ mod tests {
let settings = OverlaySettings { let settings = OverlaySettings {
font_scale: 999, font_scale: 999,
font_brightness: 1, font_brightness: 1,
viewer_color: Some(" #a1b2c3 ".into()),
danmaku_color: Some("not-css".into()),
max_visible: 99, max_visible: 99,
collapse_after_seconds: 1, collapse_after_seconds: 1,
unfold_duration_ms: 9_000, unfold_duration_ms: 9_000,
@@ -570,6 +590,8 @@ mod tests {
.sanitize(); .sanitize();
assert_eq!(settings.font_scale, 300); assert_eq!(settings.font_scale, 300);
assert_eq!(settings.font_brightness, 70); 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.max_visible, 12);
assert_eq!(settings.collapse_after_seconds, 2); assert_eq!(settings.collapse_after_seconds, 2);
assert_eq!(settings.unfold_duration_ms, 5_000); assert_eq!(settings.unfold_duration_ms, 5_000);
@@ -594,6 +616,8 @@ mod tests {
object.remove("fontScale"); object.remove("fontScale");
object.remove("fontFamily"); object.remove("fontFamily");
object.remove("fontBrightness"); object.remove("fontBrightness");
object.remove("viewerColor");
object.remove("danmakuColor");
object.remove("unfoldDurationMs"); object.remove("unfoldDurationMs");
object.remove("particleCount"); object.remove("particleCount");
object.remove("particleSpeed"); object.remove("particleSpeed");
@@ -602,6 +626,8 @@ mod tests {
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll); assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
assert_eq!(settings.font_family, FontFamilyId::FangSong); assert_eq!(settings.font_family, FontFamilyId::FangSong);
assert_eq!(settings.font_brightness, 130); 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.font_scale, 140);
assert_eq!(settings.unfold_duration_ms, 1_000); assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8); assert_eq!(settings.particle_count, 8);
+69
View File
@@ -38,6 +38,12 @@ pub struct SongRequestSettings {
pub font_family: FontFamilyId, pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")] #[serde(default = "default_font_brightness")]
pub font_brightness: u16, 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")] #[serde(default = "default_font_scale")]
pub font_scale: u16, pub font_scale: u16,
#[serde(default = "default_scroll_speed")] #[serde(default = "default_scroll_speed")]
@@ -61,6 +67,8 @@ impl Default for SongRequestSettings {
theme_id: OverlayThemeId::default(), theme_id: OverlayThemeId::default(),
font_family: FontFamilyId::default(), font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(), font_brightness: default_font_brightness(),
requester_color: None,
song_title_color: None,
font_scale: default_font_scale(), font_scale: default_font_scale(),
scroll_speed_pixels_per_second: default_scroll_speed(), scroll_speed_pixels_per_second: default_scroll_speed(),
edge_pause_seconds: default_edge_pause(), edge_pause_seconds: default_edge_pause(),
@@ -75,6 +83,8 @@ impl SongRequestSettings {
pub fn sanitize(mut self) -> Self { pub fn sanitize(mut self) -> Self {
self.font_scale = self.font_scale.clamp(50, 250); self.font_scale = self.font_scale.clamp(50, 250);
self.font_brightness = sanitize_font_brightness(self.font_brightness); 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.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.edge_pause_seconds = self.edge_pause_seconds.min(15);
self.max_queue_size = self.max_queue_size.min(10_000); 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 { const fn default_font_scale() -> u16 {
100 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( async fn request_song(
&self, &self,
component: &ComponentInstance, component: &ComponentInstance,
@@ -1031,6 +1096,8 @@ mod tests {
let bounded = SongRequestSettings { let bounded = SongRequestSettings {
font_scale: 999, font_scale: 999,
font_brightness: u16::MAX, font_brightness: u16::MAX,
requester_color: Some(" #a1b2c3 ".into()),
song_title_color: Some("transparent".into()),
scroll_speed_pixels_per_second: 0, scroll_speed_pixels_per_second: 0,
edge_pause_seconds: 200, edge_pause_seconds: 200,
max_queue_size: u32::MAX, max_queue_size: u32::MAX,
@@ -1041,6 +1108,8 @@ mod tests {
.sanitize(); .sanitize();
assert_eq!(bounded.font_scale, 250); assert_eq!(bounded.font_scale, 250);
assert_eq!(bounded.font_brightness, 180); 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.scroll_speed_pixels_per_second, 5);
assert_eq!(bounded.edge_pause_seconds, 15); assert_eq!(bounded.edge_pause_seconds, 15);
assert_eq!(bounded.max_queue_size, 10_000); assert_eq!(bounded.max_queue_size, 10_000);
+3
View File
@@ -95,6 +95,9 @@ theme_id = "jade-scroll"
font_family = "fang-song" font_family = "fang-song"
# 文字亮度百分比,范围 70-180;深色直播背景建议从 130 开始。 # 文字亮度百分比,范围 70-180;深色直播背景建议从 130 开始。
font_brightness = 130 font_brightness = 130
# 可选的弹幕昵称与正文颜色,只接受 #RRGGBB;不填写则跟随所选主题。
# viewer_color = "#B7AD83"
# danmaku_color = "#C6AE7A"
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。 # 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
font_scale = 140 font_scale = 140
max_visible = 5 max_visible = 5
+4 -2
View File
@@ -17,6 +17,8 @@
| `themeId` | 主题 ID | 选择已安装的完整视觉主题 | | `themeId` | 主题 ID | 选择已安装的完整视觉主题 |
| `fontFamily` | 字体 ID | `song`、`fang-song`、`kai` | | `fontFamily` | 字体 ID | `song`、`fang-song`、`kai` |
| `fontBrightness` | 70–180% | 只调整文字亮度 | | `fontBrightness` | 70–180% | 只调整文字亮度 |
| `viewerColor` | `#RRGGBB` | 可选的用户昵称颜色覆盖 |
| `danmakuColor` | `#RRGGBB` | 可选的弹幕正文颜色覆盖 |
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 | | `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
| `maxVisible` | 1–12 | 同时保留的消息卡数量 | | `maxVisible` | 1–12 | 同时保留的消息卡数量 |
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 | | `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
@@ -30,13 +32,13 @@
| `show*` | boolean | 控制各事件类别订阅 | | `show*` | boolean | 控制各事件类别订阅 |
后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range 后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range
input 只改善交互,不能取代服务端校验。字体 ID 只映射到前端预先注册的安全字体栈,不接受任意 CSS;OBS 设备缺少首选字体时会依次回退到可用衬线字体。 input 只改善交互,不能取代服务端校验。字体 ID 只映射到前端预先注册的安全字体栈,不接受任意 CSS;OBS 设备缺少首选字体时会依次回退到可用衬线字体。颜色覆盖同样只接受六位十六进制颜色;留空时使用主题色,切换主题后会自动采用新主题的配色。
## 主题 ## 主题
主题同时封装配色、花纹素材、装饰变体、粒子组合与动画名称。当前内置主题为 主题同时封装配色、花纹素材、装饰变体、粒子组合与动画名称。当前内置主题为
`jade-scroll`(“青玉花卷”)与 `jade-scroll`(“青玉花卷”)与
`moonlit-water`(“静夜曲水”)。后者不绘制卡片底色或外框,只保留青黛、米金衬线文字、细金分隔线与少量花星微光;控制台仍以选择框呈现,新增主题后无需改变设置交互或卡片队列。 `moonlit-water`(“静夜曲水”)。后者不绘制卡片底色、外框或消息分隔线,只保留青黛、米金衬线文字、少量花星微光以及组件上下共享的透明古风细边;控制台仍以选择框呈现,新增主题后无需改变设置交互或卡片队列。
增加主题时需要同步完成三处注册: 增加主题时需要同步完成三处注册:
+4 -3
View File
@@ -25,10 +25,11 @@ schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电
## OBS 行为 ## OBS 行为
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、滚动速度、高亮时长和动效强度均可调整。仅当内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。 每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、高亮时长和动效强度均可调整。`jade-banquet`
仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
命中时浏览器选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行、暂停自动滚动并播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子。舰长、提督和总督使用随前端打包的透明图标。 命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。
`jade-banquet` `jade-banquet`
复用仓库内已有且记录许可的花枝 SVG。`moonlit-water`(“静夜曲水”)提供另一种完全透明的排版:行项目没有卡片底色或外框,只以青黛、米金文字和细金线区分,触发时以水纹扫光高亮。新增主题必须在 复用仓库内已有且记录许可的花枝 SVG。`moonlit-water`(“静夜曲水”)提供另一种完全透明的分页排版,并与弹幕姬、点歌姬共享透明古风上下细边:每页行数和换页间隔可调,不进行纵向滚动;换页时旧内容从左向右渐隐,同时新内容沿相同方向淡入。礼物触发会以该条目为第一页首行,并以水纹扫光高亮。新增主题必须在
`giftMenuThemes.ts` 注册稳定 ID、资源键和局部 CSS 变量。 `giftMenuThemes.ts` 注册稳定 ID、资源键和局部 CSS 变量。
+4 -4
View File
@@ -13,15 +13,15 @@
## 设置 ## 设置
`themeId`、`fontFamily`、`fontBrightness`、`fontScale`、`scrollSpeedPixelsPerSecond` 和 `themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`scrollSpeedPixelsPerSecond`
`edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与 和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。 `requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲和待唱项会变为完全透明的衬线文字清单,以米金细线区分层级,不会在 OBS 上产生面板背景。 `moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲使用圆形音符、演唱状态、大号歌名、紧凑评分和弱化音频柱,待唱区按“点歌用户、歌名”两行显示。它不绘制卡片或面板背景,只用米金文字、青色微光、共享的上下古风细边和一条弱分隔线建立层级。用户和歌名颜色均可覆盖主题默认值。
## OBS 与管理 ## OBS 与管理
OBS 顶部固定显示当前歌曲、点歌用户、平均分和评分人数,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。 OBS 顶部固定显示当前歌曲、点歌用户、平均分和评分人数,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成或取消当前歌曲。所有 REST 查询都从会话取得 owner,并由 PostgreSQL 控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成、取消当前歌曲,或在确认后一次性将全部活动歌曲标记为已取消。清空操作保留历史与评分,并以一个 revision 原子广播。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
RLS 再次限制组件归属。 RLS 再次限制组件归属。
+3 -2
View File
@@ -102,8 +102,9 @@ close 结束连接。
3. `song.queue.snapshot.end`:提交该快照。 3. `song.queue.snapshot.end`:提交该快照。
后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、 后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、
`completed`、`cancelled` 或 `completed`、`cancelled`、`cleared` 或
`rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `cleared`
表示当前歌曲与全部待唱项已在同一事务中取消,客户端必须立即清空活动投影。
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny` 礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
是供展示使用的人民币数值。 是供展示使用的人民币数值。
+29 -3
View File
@@ -147,6 +147,10 @@ name = "简体中文"
"settings.font_family.kai" = "楷体 · 书卷" "settings.font_family.kai" = "楷体 · 书卷"
"settings.font_brightness" = "文字亮度" "settings.font_brightness" = "文字亮度"
"settings.font_brightness_description" = "只提亮文字,不改变透明背景、礼物图片或直播画面。" "settings.font_brightness_description" = "只提亮文字,不改变透明背景、礼物图片或直播画面。"
"settings.viewer_color" = "用户昵称颜色"
"settings.danmaku_color" = "弹幕内容颜色"
"settings.use_theme_color" = "跟随主题"
"settings.text_color_description" = "选择后覆盖主题颜色;恢复后会随主题自动变化。"
"settings.font_size" = "字号" "settings.font_size" = "字号"
"settings.max_visible" = "最大可见条数" "settings.max_visible" = "最大可见条数"
"settings.auto_collapse" = "自动收缩" "settings.auto_collapse" = "自动收缩"
@@ -175,6 +179,8 @@ name = "简体中文"
"preview.horizontal" = "横向条" "preview.horizontal" = "横向条"
"song.settings.scroll_speed" = "滚动速度" "song.settings.scroll_speed" = "滚动速度"
"song.settings.edge_pause" = "两端暂停" "song.settings.edge_pause" = "两端暂停"
"song.settings.requester_color" = "点歌用户颜色"
"song.settings.title_color" = "歌名颜色"
"song.settings.limits" = "可选防刷限制(0 表示不限制)" "song.settings.limits" = "可选防刷限制(0 表示不限制)"
"song.settings.queue_limit" = "队列上限" "song.settings.queue_limit" = "队列上限"
"song.settings.viewer_limit" = "每位观众上限" "song.settings.viewer_limit" = "每位观众上限"
@@ -285,6 +291,11 @@ name = "简体中文"
"song.completed" = "已完成并自动切换下一首。" "song.completed" = "已完成并自动切换下一首。"
"song.cancelled" = "已取消点歌。" "song.cancelled" = "已取消点歌。"
"song.action_failed" = "队列操作失败" "song.action_failed" = "队列操作失败"
"song.clear_queue" = "一键清空队列"
"song.clearing" = "正在清空…"
"song.clear_confirm" = "确定清空当前歌曲和全部待唱歌曲吗?它们会被标记为已取消并保留在历史记录中。"
"song.cleared" = "已清空点歌队列,共取消 {count} 首歌曲。"
"song.clear_failed" = "清空点歌队列失败"
"song.stats_title" = "点歌统计与队列" "song.stats_title" = "点歌统计与队列"
"song.stats_eyebrow" = "点歌队列管理" "song.stats_eyebrow" = "点歌队列管理"
"song.back_settings" = "返回组件设置" "song.back_settings" = "返回组件设置"
@@ -368,7 +379,7 @@ name = "简体中文"
"song.overlay.request_help" = "发送「点歌 歌名」加入队列" "song.overlay.request_help" = "发送「点歌 歌名」加入队列"
"song.overlay.rate_help" = "发送「打分 1-5」" "song.overlay.rate_help" = "发送「打分 1-5」"
"song.overlay.queue_aria" = "待唱队列" "song.overlay.queue_aria" = "待唱队列"
"song.overlay.queue_title" = "待唱歌单" "song.overlay.queue_title" = "待唱列表"
"song.overlay.queue_empty" = "下一首,会由谁来点呢?" "song.overlay.queue_empty" = "下一首,会由谁来点呢?"
"theme.jade_scroll.name" = "青玉花卷" "theme.jade_scroll.name" = "青玉花卷"
"theme.jade_scroll.description" = "暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。" "theme.jade_scroll.description" = "暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。"
@@ -445,8 +456,10 @@ name = "简体中文"
"gift_menu.guard_mark.governor" = "总" "gift_menu.guard_mark.governor" = "总"
"gift_menu.battery_mark" = "电" "gift_menu.battery_mark" = "电"
"gift_menu.settings.visible_rows" = "最多展示行数" "gift_menu.settings.visible_rows" = "最多展示行数"
"gift_menu.settings.rows_per_page" = "每页展示行数"
"gift_menu.settings.row_height" = "每行高度" "gift_menu.settings.row_height" = "每行高度"
"gift_menu.settings.scroll_speed" = "循环滚动速度" "gift_menu.settings.scroll_speed" = "循环滚动速度"
"gift_menu.settings.page_interval" = "换页间隔"
"gift_menu.settings.highlight_duration" = "触发高亮时长" "gift_menu.settings.highlight_duration" = "触发高亮时长"
"gift_menu.settings.font_scale" = "文字缩放" "gift_menu.settings.font_scale" = "文字缩放"
"gift_menu.settings.motion" = "动效强度" "gift_menu.settings.motion" = "动效强度"
@@ -454,7 +467,7 @@ name = "简体中文"
"gift_menu.unit.speed" = "{value} 像素/秒" "gift_menu.unit.speed" = "{value} 像素/秒"
"gift_menu.unit.seconds" = "{value} 秒" "gift_menu.unit.seconds" = "{value} 秒"
"gift_menu.preview.title" = "礼物菜单预览" "gift_menu.preview.title" = "礼物菜单预览"
"gift_menu.preview.description" = "点击任意菜单行可预览定位、暂停滚动与渐变高亮动画。" "gift_menu.preview.description" = "点击任意菜单行可预览定位与渐变高亮动画。"
"gift_menu.aria" = "直播间礼物菜单" "gift_menu.aria" = "直播间礼物菜单"
"gift_menu.overlay.battery" = "电池 ×{amount}" "gift_menu.overlay.battery" = "电池 ×{amount}"
"gift_menu.overlay.triggered_by" = "{viewer} 已触发" "gift_menu.overlay.triggered_by" = "{viewer} 已触发"
@@ -613,6 +626,10 @@ name = "English"
"settings.font_family.kai" = "Kai · Calligraphic" "settings.font_family.kai" = "Kai · Calligraphic"
"settings.font_brightness" = "Text brightness" "settings.font_brightness" = "Text brightness"
"settings.font_brightness_description" = "Brightens text only; transparency, gift artwork, and the underlying scene are unchanged." "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.font_size" = "Font size"
"settings.max_visible" = "Maximum visible items" "settings.max_visible" = "Maximum visible items"
"settings.auto_collapse" = "Auto-collapse" "settings.auto_collapse" = "Auto-collapse"
@@ -641,6 +658,8 @@ name = "English"
"preview.horizontal" = "Horizontal bar" "preview.horizontal" = "Horizontal bar"
"song.settings.scroll_speed" = "Scroll speed" "song.settings.scroll_speed" = "Scroll speed"
"song.settings.edge_pause" = "Edge pause" "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.limits" = "Optional anti-spam limits (0 means unlimited)"
"song.settings.queue_limit" = "Queue limit" "song.settings.queue_limit" = "Queue limit"
"song.settings.viewer_limit" = "Per-viewer limit" "song.settings.viewer_limit" = "Per-viewer limit"
@@ -751,6 +770,11 @@ name = "English"
"song.completed" = "Completed and advanced to the next song." "song.completed" = "Completed and advanced to the next song."
"song.cancelled" = "Song request cancelled." "song.cancelled" = "Song request cancelled."
"song.action_failed" = "Queue operation failed" "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_title" = "Song statistics and queue"
"song.stats_eyebrow" = "SONG QUEUE MANAGER" "song.stats_eyebrow" = "SONG QUEUE MANAGER"
"song.back_settings" = "Back to component settings" "song.back_settings" = "Back to component settings"
@@ -911,8 +935,10 @@ name = "English"
"gift_menu.guard_mark.governor" = "V" "gift_menu.guard_mark.governor" = "V"
"gift_menu.battery_mark" = "B" "gift_menu.battery_mark" = "B"
"gift_menu.settings.visible_rows" = "Maximum visible rows" "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.row_height" = "Row height"
"gift_menu.settings.scroll_speed" = "Loop scroll speed" "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.highlight_duration" = "Trigger highlight duration"
"gift_menu.settings.font_scale" = "Text scale" "gift_menu.settings.font_scale" = "Text scale"
"gift_menu.settings.motion" = "Motion intensity" "gift_menu.settings.motion" = "Motion intensity"
@@ -920,7 +946,7 @@ name = "English"
"gift_menu.unit.speed" = "{value} px/s" "gift_menu.unit.speed" = "{value} px/s"
"gift_menu.unit.seconds" = "{value} s" "gift_menu.unit.seconds" = "{value} s"
"gift_menu.preview.title" = "Gift menu preview" "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.aria" = "Live room gift menu"
"gift_menu.overlay.battery" = "Battery ×{amount}" "gift_menu.overlay.battery" = "Battery ×{amount}"
"gift_menu.overlay.triggered_by" = "Triggered by {viewer}" "gift_menu.overlay.triggered_by" = "Triggered by {viewer}"