{copies.flatMap(copy =>
diff --git a/apps/overlay/src/giftMenuThemes.ts b/apps/overlay/src/giftMenuThemes.ts
index fbffee3..a4c61e7 100644
--- a/apps/overlay/src/giftMenuThemes.ts
+++ b/apps/overlay/src/giftMenuThemes.ts
@@ -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})`,
}
}
diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx
index dbe4803..3e4f423 100644
--- a/apps/overlay/src/main.tsx
+++ b/apps/overlay/src/main.tsx
@@ -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)
diff --git a/apps/overlay/src/overlay.tsx b/apps/overlay/src/overlay.tsx
index 93a709d..333bc2a 100644
--- a/apps/overlay/src/overlay.tsx
+++ b/apps/overlay/src/overlay.tsx
@@ -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`,
diff --git a/apps/overlay/src/song.css b/apps/overlay/src/song.css
index fc454fa..2df8874 100644
--- a/apps/overlay/src/song.css
+++ b/apps/overlay/src/song.css
@@ -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;
+ }
+}
diff --git a/apps/overlay/src/songOverlay.tsx b/apps/overlay/src/songOverlay.tsx
index 41806a5..265c3d4 100644
--- a/apps/overlay/src/songOverlay.tsx
+++ b/apps/overlay/src/songOverlay.tsx
@@ -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({
>
- {translate('song.overlay.sing_mark')}
+ {translate('song.overlay.sing_mark')}
+ ♫
{queue.current ? (
@@ -339,6 +357,11 @@ export function SongRequestOverlay({
{score(queue.current)}
{translate('song.overlay.rate_help')}
+
+ {Array.from({ length: 8 }, (_, index) => (
+
+ ))}
+
@@ -355,8 +378,10 @@ export function SongRequestOverlay({
style={{ ['--song-row-delay' as string]: `${Math.min(index, 6) * 35}ms` }}
>
{String(index + 1).padStart(2, '0')}
- {item.title}
- {item.requester.name}
+
+ {item.requester.name}
+ {item.title}
+
))}
{queue.queued.length === 0 && (
diff --git a/apps/overlay/src/themeEdges.css b/apps/overlay/src/themeEdges.css
new file mode 100644
index 0000000..767bc33
--- /dev/null
+++ b/apps/overlay/src/themeEdges.css
@@ -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;
+ }
+}
diff --git a/apps/overlay/src/themes/index.ts b/apps/overlay/src/themes/index.ts
index 54ca60a..a5f88f6 100644
--- a/apps/overlay/src/themes/index.ts
+++ b/apps/overlay/src/themes/index.ts
@@ -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,
diff --git a/apps/overlay/src/themes/jadeScroll.ts b/apps/overlay/src/themes/jadeScroll.ts
index 33a1826..fb5d8ae 100644
--- a/apps/overlay/src/themes/jadeScroll.ts
+++ b/apps/overlay/src/themes/jadeScroll.ts
@@ -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',
diff --git a/apps/overlay/src/themes/moonlitWater.ts b/apps/overlay/src/themes/moonlitWater.ts
index b172fdf..4649678 100644
--- a/apps/overlay/src/themes/moonlitWater.ts
+++ b/apps/overlay/src/themes/moonlitWater.ts
@@ -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',
diff --git a/apps/overlay/src/themes/types.ts b/apps/overlay/src/themes/types.ts
index ce50e84..1b9cb00 100644
--- a/apps/overlay/src/themes/types.ts
+++ b/apps/overlay/src/themes/types.ts
@@ -34,6 +34,7 @@ export type OverlayThemeDefinition = {
divider: string
cluster: string
vine: string
+ edge: string
}
}
motion: {
diff --git a/apps/overlay/src/types.ts b/apps/overlay/src/types.ts
index ce7e1ee..bc5cf9d 100644
--- a/apps/overlay/src/types.ts
+++ b/apps/overlay/src/types.ts
@@ -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,
diff --git a/apps/server-rust/src/config.rs b/apps/server-rust/src/config.rs
index 574951a..0c0aca6 100644
--- a/apps/server-rust/src/config.rs
+++ b/apps/server-rust/src/config.rs
@@ -130,6 +130,8 @@ struct OverlayFileConfig {
theme_id: Option,
font_family: Option,
font_brightness: Option,
+ viewer_color: Option,
+ danmaku_color: Option,
font_scale: Option,
max_visible: Option,
collapse_after_seconds: Option,
@@ -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),
diff --git a/apps/server-rust/src/gift_menu.rs b/apps/server-rust/src/gift_menu.rs
index 0291047..6c1efd3 100644
--- a/apps/server-rust/src/gift_menu.rs
+++ b/apps/server-rust/src/gift_menu.rs
@@ -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();
diff --git a/apps/server-rust/src/http_api.rs b/apps/server-rust/src/http_api.rs
index a40b7c2..3004c25 100644
--- a/apps/server-rust/src/http_api.rs
+++ b/apps/server-rust/src/http_api.rs
@@ -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,
+ headers: HeaderMap,
+ Path(id): Path,
+) -> Result, 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,
diff --git a/apps/server-rust/src/overlay.rs b/apps/server-rust/src/overlay.rs
index 042e67c..7a75c1f 100644
--- a/apps/server-rust/src/overlay.rs
+++ b/apps/server-rust/src/overlay.rs
@@ -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,
+ #[serde(default)]
+ pub danmaku_color: Option,
#[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) -> Option {
+ 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);
diff --git a/apps/server-rust/src/song_request.rs b/apps/server-rust/src/song_request.rs
index a6cf265..51e90e4 100644
--- a/apps/server-rust/src/song_request.rs
+++ b/apps/server-rust/src/song_request.rs
@@ -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,
+ #[serde(default)]
+ pub song_title_color: Option,
#[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) -> Option {
+ 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 {
+ 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);
diff --git a/config.toml.example b/config.toml.example
index 6a5648f..39717c6 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -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
diff --git a/docs/components/danmaku-overlay.md b/docs/components/danmaku-overlay.md
index bc708a6..f3e003f 100644
--- a/docs/components/danmaku-overlay.md
+++ b/docs/components/danmaku-overlay.md
@@ -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`(“静夜曲水”)。后者不绘制卡片底色、外框或消息分隔线,只保留青黛、米金衬线文字、少量花星微光以及组件上下共享的透明古风细边;控制台仍以选择框呈现,新增主题后无需改变设置交互或卡片队列。
增加主题时需要同步完成三处注册:
diff --git a/docs/components/gift-menu.md b/docs/components/gift-menu.md
index 7b74c46..052aae0 100644
--- a/docs/components/gift-menu.md
+++ b/docs/components/gift-menu.md
@@ -25,10 +25,11 @@ schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电
## OBS 行为
-每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、滚动速度、高亮时长和动效强度均可调整。仅当内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
+每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、高亮时长和动效强度均可调整。`jade-banquet`
+仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
-命中时浏览器选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行、暂停自动滚动并播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子。舰长、提督和总督使用随前端打包的透明图标。
+命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。
`jade-banquet`
-复用仓库内已有且记录许可的花枝 SVG。`moonlit-water`(“静夜曲水”)提供另一种完全透明的排版:行项目没有卡片底色或外框,只以青黛、米金文字和细金线区分,触发时以水纹扫光高亮。新增主题必须在
+复用仓库内已有且记录许可的花枝 SVG。`moonlit-water`(“静夜曲水”)提供另一种完全透明的分页排版,并与弹幕姬、点歌姬共享透明古风上下细边:每页行数和换页间隔可调,不进行纵向滚动;换页时旧内容从左向右渐隐,同时新内容沿相同方向淡入。礼物触发会以该条目为第一页首行,并以水纹扫光高亮。新增主题必须在
`giftMenuThemes.ts` 注册稳定 ID、资源键和局部 CSS 变量。
diff --git a/docs/components/song-request.md b/docs/components/song-request.md
index c3c5151..2a849e6 100644
--- a/docs/components/song-request.md
+++ b/docs/components/song-request.md
@@ -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 再次限制组件归属。
diff --git a/docs/protocol.md b/docs/protocol.md
index c40fff2..24fe848 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -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`
是供展示使用的人民币数值。
diff --git a/resources/i18n.toml b/resources/i18n.toml
index 55092e3..3e95072 100644
--- a/resources/i18n.toml
+++ b/resources/i18n.toml
@@ -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}"