新主题

This commit is contained in:
2026-08-06 00:41:10 -07:00
parent 7eb9d00a50
commit ac80cd1db4
35 changed files with 1693 additions and 24 deletions
+5
View File
@@ -24,6 +24,7 @@ import type {
TotpEnrollment,
} from './types'
import { normalizeThemeId } from './themes'
import { normalizeFontFamilyId } from './typography'
import { normalizeGiftEffectThemeId } from './giftThemes'
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
import {
@@ -212,6 +213,7 @@ export function normalizeSettings(value: unknown): OverlaySettings {
...defaultOverlaySettings,
...(settings as Partial<OverlaySettings>),
themeId: normalizeThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
}
}
@@ -222,6 +224,7 @@ export function normalizeSongRequestSettings(value: unknown): SongRequestSetting
...defaultSongRequestSettings,
...(settings as Partial<SongRequestSettings>),
themeId: normalizeThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
}
}
@@ -235,6 +238,7 @@ export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings
...defaultGiftEffectSettings,
...(settings as Partial<GiftEffectSettings>),
themeId: normalizeGiftEffectThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
normal: { ...defaultGiftEffectSettings.normal, ...normal },
high: { ...defaultGiftEffectSettings.high, ...high },
featured: { ...defaultGiftEffectSettings.featured, ...featured },
@@ -295,6 +299,7 @@ export function normalizeGiftMenuSettings(value: unknown): GiftMenuSettings {
...defaultGiftMenuSettings,
...(settings as Partial<GiftMenuSettings>),
themeId: normalizeGiftMenuThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
items,
}
}
+300 -2
View File
@@ -1,6 +1,7 @@
.overlay {
align-items: flex-start;
background: transparent;
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
}
.theme-jade-scroll {
@@ -366,8 +367,7 @@
html,
body,
#root,
.copy {
#root {
font-family:
'Noto Serif SC', 'Microsoft YaHei', 'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji',
serif;
@@ -555,12 +555,24 @@ body,
}
.card > .gift-art,
.card > .viewer-avatar,
.card > .copy,
.card > .particles {
position: relative;
z-index: 3;
}
.viewer-avatar {
display: none;
}
.viewer-avatar img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-particle-layer {
position: absolute;
inset: 0;
@@ -720,7 +732,9 @@ body,
}
.copy {
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
font-size: var(--font-body, 25px);
filter: brightness(var(--component-font-brightness, 1.3));
}
.copy span {
@@ -960,6 +974,267 @@ body,
box-shadow: 0 0 0 2px rgba(137, 255, 226, 0.28);
}
/* -------------------------------------------------------------------------- */
/* Moonlit Water: text first, alpha always preserved */
/* -------------------------------------------------------------------------- */
.overlay.theme-moonlit-water {
align-items: flex-start;
justify-content: flex-start;
padding: clamp(14px, 2.3vw, 34px);
background: transparent;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.theme-moonlit-water .wall {
width: min(100%, 620px);
gap: 0;
filter: none;
}
.theme-moonlit-water .cards {
gap: clamp(24px, 3.8vh, 46px);
}
.theme-moonlit-water .card {
min-height: 0;
gap: clamp(8px, 1.1vw, 14px);
padding: clamp(8px, 1.1vw, 15px) clamp(4px, 0.7vw, 10px);
border: 0;
border-bottom: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.theme-moonlit-water .card::before {
display: none;
}
.theme-moonlit-water .card-decor::before,
.theme-moonlit-water .card-decor::after,
.theme-moonlit-water .card-decor-surface {
display: none;
}
.theme-moonlit-water .card-particle-layer {
opacity: 0.34;
}
.theme-moonlit-water .card-particle.star {
background: #e7c982;
filter: drop-shadow(0 0 3px rgba(231, 201, 130, 0.58));
}
.theme-moonlit-water .card-particle.floret {
background:
radial-gradient(circle at 50% 17%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 82% 50%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 50% 83%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #e7c982 0 18%, transparent 21%);
filter: none;
}
.theme-moonlit-water .copy {
color: #c6ae7a;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
letter-spacing: 0.075em;
line-height: 1.55;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
}
.theme-moonlit-water .copy b {
color: #b7ad83;
font-weight: 600;
letter-spacing: 0.14em;
}
.theme-moonlit-water .copy span {
color: #c6ae7a;
letter-spacing: 0.075em;
line-height: 1.55;
}
.theme-moonlit-water .card.danmaku .copy {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto;
align-items: center;
gap: 0.16em 0.56em;
width: 100%;
}
.theme-moonlit-water .card.danmaku .copy > b {
grid-column: 2;
grid-row: 1;
line-height: 1.25;
}
.theme-moonlit-water .card.danmaku .copy > .danmaku-content {
grid-column: 1 / -1;
grid-row: 2;
line-height: 1.35;
}
.theme-moonlit-water .card.danmaku .viewer-avatar {
display: grid;
grid-column: 1;
grid-row: 1;
flex: 0 0 auto;
align-self: center;
width: clamp(38px, 2.3em, 56px);
height: clamp(38px, 2.3em, 56px);
overflow: hidden;
place-items: center;
border: 1px solid rgba(231, 201, 130, 0.58);
border-radius: 50%;
background: radial-gradient(circle at 36% 28%, rgba(224, 233, 205, 0.32), rgba(22, 72, 76, 0.5));
color: #d2b976;
font-size: 0.86em;
font-weight: 600;
line-height: 1;
box-shadow:
0 0 0 2px rgba(22, 72, 76, 0.28),
0 0 12px rgba(231, 201, 130, 0.18);
transition:
width 0.28s ease,
height 0.28s ease;
}
.theme-moonlit-water .card.danmaku .viewer-avatar img {
filter: saturate(0.84) brightness(1.06);
}
.theme-moonlit-water .copy em,
.theme-moonlit-water .particles {
color: #d2ae5e;
}
.theme-moonlit-water .gift-art {
width: clamp(38px, 4.2vw, 58px);
height: clamp(38px, 4.2vw, 58px);
border-radius: 0;
background: transparent;
}
.theme-moonlit-water .gift-art img {
filter: saturate(0.76) sepia(0.1) brightness(1.08) drop-shadow(0 0 6px rgba(231, 201, 130, 0.44));
}
.theme-moonlit-water .gift.high,
.theme-moonlit-water .gift.featured {
min-height: 0;
border-color: rgba(231, 201, 130, 0.47);
background: transparent;
animation: moonlit-gift-breathe 3.4s ease-in-out infinite;
}
.theme-moonlit-water .gift.featured .gift-art {
width: clamp(46px, 5vw, 68px);
height: clamp(46px, 5vw, 68px);
}
.theme-moonlit-water .card.danmaku {
transition: padding 0.28s ease;
}
.theme-moonlit-water .card.danmaku.expanded,
.theme-moonlit-water .card.danmaku.compact {
min-height: 0;
padding: clamp(10px, 1.25vw, 18px) clamp(4px, 0.7vw, 10px);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.theme-moonlit-water .card.danmaku.expanded {
animation: moonlit-reveal var(--unfold-duration, 1000ms) cubic-bezier(0.2, 0.75, 0.25, 1) both;
}
.theme-moonlit-water .card.danmaku.expanded::after {
display: none;
}
.theme-moonlit-water .card.danmaku.expanded .copy {
gap: 0.16em 0.56em;
padding: 0;
font-size: var(--font-expanded, 36px);
}
.theme-moonlit-water .card.danmaku.compact .copy {
gap: 0.14em 0.52em;
font-size: var(--font-compact, 21px);
}
.theme-moonlit-water .card.danmaku.compact .copy > .danmaku-content {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.theme-moonlit-water .card.danmaku.compact .viewer-avatar {
width: clamp(32px, 1.9em, 44px);
height: clamp(32px, 1.9em, 44px);
}
.theme-moonlit-water .danmaku-emoticon {
filter: drop-shadow(0 1px 3px rgba(10, 34, 38, 0.56));
}
.theme-moonlit-water .particles {
opacity: 0.76;
}
.theme-moonlit-water .card.expanded .card-particle-layer {
opacity: 0.64;
}
@keyframes moonlit-reveal {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
@keyframes moonlit-rule-reveal {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes moonlit-gift-breathe {
50% {
border-color: rgba(231, 201, 130, 0.8);
filter: brightness(1.14);
}
}
@keyframes moonlit-drift {
50% {
opacity: 0.46;
transform: translateY(-4px);
}
}
@keyframes moonlit-sparkle {
0%,
100% {
opacity: 0.03;
transform: translate3d(0, 2px, 0) rotate(0) scale(0.4);
}
44% {
opacity: 0.76;
transform: translate3d(1px, -2px, 0) rotate(32deg) scale(0.95);
}
}
.control .obs-address {
margin-top: 16px;
}
@@ -1749,6 +2024,28 @@ select {
margin-bottom: 24px;
}
.typography-settings {
max-width: 780px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 24px;
}
.typography-brightness > span {
display: flex;
justify-content: space-between;
gap: 10px;
}
.typography-brightness output {
color: #8aebd5;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.typography-brightness input[type='range'] {
width: 100%;
accent-color: #63ddc1;
}
.slider-grid label {
display: grid;
gap: 9px;
@@ -1993,6 +2290,7 @@ td:last-child {
.totp-enrollment,
.slider-grid,
.typography-settings,
.two-columns {
grid-template-columns: 1fr;
}
+71
View File
@@ -34,6 +34,8 @@ import { PwaControls, usePwaUpdateBlocker } from './pwa'
import { SongRequestOverlay } from './songOverlay'
import { getOverlayTheme, overlayThemes } from './themes'
import { currentLanguage, LanguageSelect, translate, useI18n } from './i18n'
import { fontFamilies } from './typography'
import type { FontFamilyId } from './typography'
import {
defaultGiftEffectSettings,
defaultGiftMenuSettings,
@@ -68,6 +70,51 @@ const previewPresets = [
{ id: 'horizontal', labelKey: 'preview.horizontal', width: 720, height: 320 },
]
function TypographySettingsFields({
fontFamily,
fontBrightness,
onFontFamily,
onFontBrightness,
}: {
fontFamily: FontFamilyId
fontBrightness: number
onFontFamily: (font: FontFamilyId) => void
onFontBrightness: (brightness: number) => void
}) {
return (
<div className="field-grid typography-settings">
<label>
{translate('settings.font_family')}
<select
value={fontFamily}
onChange={event => onFontFamily(event.target.value as FontFamilyId)}
>
{fontFamilies.map(font => (
<option value={font.id} key={font.id}>
{translate(font.nameKey)}
</option>
))}
</select>
<small>{translate('settings.font_family_description')}</small>
</label>
<label className="typography-brightness">
<span>
{translate('settings.font_brightness')} <output>{fontBrightness}%</output>
</span>
<input
type="range"
min="70"
max="180"
step="5"
value={fontBrightness}
onChange={event => onFontBrightness(+event.target.value)}
/>
<small>{translate('settings.font_brightness_description')}</small>
</label>
</div>
)
}
function isDanmakuKind(kind: string): boolean {
return kind === 'danmaku_overlay' || kind === 'danmaku'
}
@@ -227,6 +274,12 @@ function SettingsEditor({
<small>{translate(selectedTheme.descriptionKey)}</small>
</label>
</div>
<TypographySettingsFields
fontFamily={settings.fontFamily}
fontBrightness={settings.fontBrightness}
onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', value)}
/>
<div className="slider-grid">
<label>
<span>
@@ -434,6 +487,12 @@ function SongRequestSettingsEditor({
<small>{translate(getOverlayTheme(settings.themeId).descriptionKey)}</small>
</label>
</div>
<TypographySettingsFields
fontFamily={settings.fontFamily}
fontBrightness={settings.fontBrightness}
onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', value)}
/>
<div className="slider-grid">
<label>
<span>
@@ -588,6 +647,12 @@ function GiftEffectSettingsEditor({
<small>{translate(theme.descriptionKey)}</small>
</label>
</div>
<TypographySettingsFields
fontFamily={settings.fontFamily}
fontBrightness={settings.fontBrightness}
onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', value)}
/>
<fieldset className="limit-grid gift-thresholds">
<legend>{translate('gift.settings.thresholds')}</legend>
<label>
@@ -904,6 +969,12 @@ function GiftMenuSettingsEditor({
</button>
</div>
</div>
<TypographySettingsFields
fontFamily={settings.fontFamily}
fontBrightness={settings.fontBrightness}
onFontFamily={value => edit('fontFamily', value)}
onFontBrightness={value => edit('fontBrightness', value)}
/>
{catalogError && <div className="notice error">{catalogError}</div>}
<fieldset className="gift-menu-builder">
+438
View File
@@ -6,6 +6,7 @@
height: 100%;
overflow: hidden;
color: var(--gift-cyan, #a9fff2);
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
background: transparent;
pointer-events: none;
}
@@ -353,3 +354,440 @@
animation: none;
}
}
/* -------------------------------------------------------------------------- */
/* Moonlit Water */
/* -------------------------------------------------------------------------- */
/* This theme intentionally has no scene-sized colour wash. Every visible
pixel below belongs to an effect, allowing OBS to retain a true alpha
background over games, cameras and artwork. */
.gift-theme-moonlit-water {
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.moonlit-whisper-copy,
.moonlit-gift-whisper > small,
.moonlit-ceremony-copy,
.guard-copy {
filter: brightness(var(--component-font-brightness, 1.3));
}
.gift-theme-moonlit-water .moonlit-gift-whisper {
position: absolute;
z-index: 5;
display: grid;
min-width: min(82vw, 310px);
grid-template-columns: 2.5em minmax(0, 1fr) auto;
align-items: center;
gap: 0.65em;
color: #eadfc3;
font-size: clamp(14px, 1.15vw, 23px);
font-weight: 500;
letter-spacing: 0.045em;
opacity: 0;
pointer-events: none;
text-shadow: 0 1px 7px rgba(11, 40, 42, 0.82);
transform: translate3d(18px, 0, 0);
animation: moonlit-whisper-appear 7.2s ease-in-out var(--moonlit-whisper-delay) both;
}
.moonlit-whisper-ripple {
position: absolute;
right: 0;
bottom: -0.35em;
left: 2.7em;
height: 1px;
opacity: 0.65;
background: linear-gradient(90deg, transparent, #e7c982 18% 72%, transparent);
box-shadow: 0 0 7px rgba(231, 201, 130, 0.38);
transform-origin: left;
animation: moonlit-ripple-line 2.8s ease-out var(--moonlit-whisper-delay) both;
}
.moonlit-whisper-image,
.moonlit-ceremony-image {
display: grid;
place-items: center;
}
.moonlit-whisper-image {
width: 2.25em;
height: 2.25em;
}
.moonlit-whisper-image img,
.moonlit-ceremony-image img {
width: 100%;
height: 100%;
object-fit: contain;
filter: saturate(0.78) sepia(0.12) brightness(1.12) drop-shadow(0 0 8px rgba(231, 201, 130, 0.48));
}
.moonlit-whisper-image .meteor-fallback,
.moonlit-ceremony-image .meteor-fallback {
color: #e7c982;
font-size: 1.55em;
text-shadow: 0 0 10px rgba(231, 201, 130, 0.7);
}
.moonlit-whisper-copy {
display: grid;
min-width: 0;
gap: 0.08em;
}
.moonlit-whisper-copy b {
overflow: hidden;
color: #b9caaa;
font-size: 0.76em;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.moonlit-whisper-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.moonlit-gift-whisper small {
align-self: end;
color: #e7c982;
font-size: 0.75em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.gift-theme-moonlit-water .moonlit-gift-ceremony {
position: absolute;
inset: 0;
z-index: 12;
display: grid;
place-items: center;
overflow: hidden;
color: #eadfc3;
isolation: isolate;
opacity: 0;
pointer-events: none;
animation: moonlit-offering-sweep 8.4s ease-in-out both;
}
.moonlit-ceremony-moon {
position: absolute;
top: 50%;
left: 50%;
z-index: -3;
width: min(44vmin, 560px);
aspect-ratio: 1;
border-radius: 50%;
opacity: 0.76;
background:
radial-gradient(circle at 35% 30%, rgba(255, 250, 226, 0.8), transparent 6%),
radial-gradient(circle at 68% 43%, rgba(180, 194, 168, 0.24), transparent 12%),
radial-gradient(circle at 42% 72%, rgba(172, 186, 159, 0.2), transparent 9%),
radial-gradient(
circle,
#f4e8c8 0 43%,
#d9d5b7 64%,
rgba(212, 218, 188, 0.12) 71%,
transparent 72%
);
box-shadow: 0 0 72px rgba(239, 224, 180, 0.23);
transform: translate(-50%, -50%) scale(0.25);
animation: moonlit-moon-rise 8.4s cubic-bezier(0.2, 0.78, 0.25, 1) both;
}
.moonlit-ceremony-water {
position: absolute;
right: 0;
bottom: 7%;
left: 0;
z-index: -2;
height: 30%;
opacity: 0.9;
}
.moonlit-ceremony-water i {
position: absolute;
left: 50%;
width: min(60vw, 1040px);
height: clamp(14px, 2.1vmin, 28px);
border-top: 1px solid rgba(234, 223, 195, 0.6);
border-radius: 50%;
box-shadow: 0 -2px 16px rgba(154, 187, 167, 0.26);
transform: translateX(-50%) scaleX(0.12);
animation: moonlit-water-spread 5.5s ease-out both;
}
.moonlit-ceremony-water i:nth-child(1) {
bottom: 5%;
}
.moonlit-ceremony-water i:nth-child(2) {
bottom: 25%;
width: min(47vw, 780px);
animation-delay: 0.42s;
}
.moonlit-ceremony-water i:nth-child(3) {
bottom: 45%;
width: min(33vw, 560px);
animation-delay: 0.72s;
}
.moonlit-ceremony-stars,
.moonlit-ceremony-stars i {
position: absolute;
inset: 0;
}
.moonlit-ceremony-stars i {
inset: auto;
width: var(--moonlit-size);
height: var(--moonlit-size);
opacity: 0;
background: #eadfc3;
clip-path: polygon(50% 0, 59% 41%, 100% 50%, 59% 59%, 50% 100%, 41% 59%, 0 50%, 41% 41%);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.78));
animation: moonlit-ceremony-sparkle 2.4s ease-in-out var(--moonlit-delay) infinite;
}
.moonlit-ceremony-copy {
position: relative;
z-index: 2;
display: grid;
max-width: min(80vw, 940px);
justify-items: center;
gap: clamp(5px, 0.9vmin, 12px);
padding-bottom: min(17vmin, 180px);
text-align: center;
text-shadow: 0 2px 11px rgba(10, 38, 40, 0.88);
}
.moonlit-ceremony-copy span {
color: #b9caaa;
font-size: clamp(12px, 1.35vw, 25px);
letter-spacing: 0.5em;
}
.moonlit-ceremony-copy b {
color: #e7c982;
font-size: clamp(18px, 2.15vw, 41px);
font-weight: 500;
letter-spacing: 0.16em;
}
.moonlit-ceremony-copy strong {
color: #eadfc3;
font-size: clamp(27px, 4.5vw, 84px);
font-weight: 500;
letter-spacing: 0.08em;
overflow-wrap: anywhere;
}
.moonlit-ceremony-copy small {
color: #e7c982;
font-size: clamp(13px, 1.55vw, 29px);
font-variant-numeric: tabular-nums;
}
.moonlit-ceremony-image {
position: absolute;
bottom: max(7%, 4vmin);
left: 50%;
z-index: 3;
width: min(14vmin, 176px);
aspect-ratio: 1;
transform: translateX(-50%);
}
.moonlit-ceremony-image::before {
content: '';
position: absolute;
inset: -22%;
z-index: -1;
border: 1px solid rgba(231, 201, 130, 0.7);
border-radius: 50%;
box-shadow:
0 0 26px rgba(231, 201, 130, 0.32),
inset 0 0 18px rgba(196, 219, 196, 0.16);
animation: moonlit-image-halo 2.8s ease-in-out infinite;
}
/* Reuse the membership DOM but make its backdrop transparent and turn the
generic nebula into a quiet, full-scene moon-and-water celebration. */
.gift-theme-moonlit-water .guard-celebration {
color: #eadfc3;
background: transparent;
font-family: inherit;
}
.gift-theme-moonlit-water .guard-celebration::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: min(62vmin, 780px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(246, 237, 207, 0.82) 0 42%,
rgba(226, 224, 193, 0.2) 63%,
transparent 71%
);
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
transform: translate(-50%, -50%);
}
.gift-theme-moonlit-water .guard-nebula {
background:
repeating-radial-gradient(
ellipse at 50% 80%,
transparent 0 7%,
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
transparent 7.7% 12%
),
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
filter: blur(1px);
animation: moonlit-guard-water 7s ease-in-out infinite;
}
.gift-theme-moonlit-water .guard-stars i {
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
}
.gift-theme-moonlit-water .guard-halo {
width: min(77vmin, 930px);
border-color: rgba(231, 201, 130, 0.42);
box-shadow:
0 0 44px rgba(231, 201, 130, 0.18),
inset 0 0 65px rgba(182, 207, 180, 0.1);
}
.gift-theme-moonlit-water .guard-halo i {
border-color: rgba(203, 219, 184, 0.36);
}
.gift-theme-moonlit-water .guard-copy {
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
}
.gift-theme-moonlit-water .guard-copy span,
.gift-theme-moonlit-water .guard-copy b {
color: #e7c982;
}
.gift-theme-moonlit-water .guard-copy strong {
color: #eadfc3;
filter: none;
}
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-moon,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-water i,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-stars,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-image::before {
animation: none;
}
@keyframes moonlit-whisper-appear {
0%,
100% {
opacity: 0;
transform: translate3d(18px, 0, 0);
}
12%,
78% {
opacity: 1;
transform: none;
}
}
@keyframes moonlit-ripple-line {
from {
opacity: 0;
transform: scaleX(0.15);
}
28%,
78% {
opacity: 0.7;
transform: scaleX(1);
}
to {
opacity: 0;
transform: scaleX(1.18);
}
}
@keyframes moonlit-offering-sweep {
0%,
100% {
opacity: 0;
}
9%,
85% {
opacity: 1;
}
}
@keyframes moonlit-moon-rise {
0% {
opacity: 0;
transform: translate(-50%, -40%) scale(0.2);
}
22%,
78% {
opacity: 0.76;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -55%) scale(1.08);
}
}
@keyframes moonlit-water-spread {
0% {
opacity: 0;
transform: translateX(-50%) scaleX(0.12);
}
20%,
76% {
opacity: 0.8;
transform: translateX(-50%) scaleX(1);
}
100% {
opacity: 0;
transform: translateX(-50%) scaleX(1.2);
}
}
@keyframes moonlit-ceremony-sparkle {
0%,
100% {
opacity: 0.06;
transform: scale(0.45) rotate(0);
}
46% {
opacity: 0.95;
transform: scale(1.12) rotate(42deg);
}
}
@keyframes moonlit-image-halo {
50% {
opacity: 0.46;
transform: scale(1.15);
}
}
@keyframes moonlit-guard-water {
50% {
opacity: 0.68;
transform: scale(1.04) translateY(-1.4%);
}
}
+107 -1
View File
@@ -7,6 +7,7 @@ import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftEffectSettings } from './types'
import type { GiftEffectSettings, MeteorTierSettings } from './types'
import { typographyVariables } from './typography'
type Viewer = { uid?: string; name?: string }
type Gift = {
@@ -37,6 +38,9 @@ type VisualEffect = {
type GiftTier = 'normal' | 'high' | 'featured'
export type GiftEffectPreviewMode = GiftTier | 'guard'
/** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */
const MOONLIT_CEREMONY_THRESHOLD = 50_000
function hash(value: string): number {
let result = 2166136261
for (let index = 0; index < value.length; index += 1) {
@@ -245,6 +249,95 @@ function MeteorBurst({
)
}
/**
* The small-gift treatment for the sparse moonlit theme. It is intentionally
* compact and stays out of the centre of a broadcaster's scene: a gift image,
* name and a single water-line appear briefly without adding a panel behind it.
*/
function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
const seed = hash(effect.id)
return (
<article
className="moonlit-gift-whisper"
aria-label={gift.name || translate('common.gift')}
style={
{
top: `${8 + (seed % 72)}%`,
right: `${3 + ((seed >>> 8) % 10)}%`,
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
} as CSSProperties
}
>
<i className="moonlit-whisper-ripple" aria-hidden="true" />
<span className="moonlit-whisper-image">
<GiftImage gift={gift} />
</span>
<div className="moonlit-whisper-copy">
<b>{viewer}</b>
<span>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</span>
</div>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</article>
)
}
/**
* A full-viewport but transparent offering ceremony for CNY 50+ gifts. The
* CSS draws moon, water ripples and falling points of light, keeping the gift
* image itself at the centre of the effect and leaving the OBS scene visible.
*/
function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: GiftTier }) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
return (
<section className={`moonlit-gift-ceremony tier-${tier}`} aria-live="polite">
<div className="moonlit-ceremony-moon" aria-hidden="true" />
<div className="moonlit-ceremony-water" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="moonlit-ceremony-stars" aria-hidden="true">
{Array.from({ length: 14 }, (_, index) => {
const seed = hash(`${effect.id}:ceremony:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--moonlit-delay' as string]: `${-((seed >>> 16) % 2_400)}ms`,
['--moonlit-size' as string]: `${4 + ((seed >>> 24) % 10)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="moonlit-ceremony-copy">
<span>{translate('gift.moonlit.ceremony_label')}</span>
<b>{viewer}</b>
<strong>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</strong>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</div>
<div className="moonlit-ceremony-image">
<GiftImage gift={gift} />
</div>
</section>
)
}
function GuardCelebration({
effect,
settings,
@@ -336,19 +429,32 @@ export function GiftEffectOverlay({
const scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
const guard = remote.effects.find(effect => effect.kind === 'guard')
const moonlit = theme.id === 'moonlit-water'
return (
<main
ref={root}
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
style={giftThemeVariables(theme)}
style={{
...giftThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
<div className="meteor-sky" aria-live="polite">
{remote.effects
.filter(effect => effect.kind === 'gift')
.map(effect => {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
if (moonlit) {
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} key={effect.id} />
)
}
return (
<MeteorBurst
effect={effect}
+141
View File
@@ -13,6 +13,7 @@ body,
overflow: hidden;
padding: clamp(4px, 1.2vmin, 14px);
color: #eafffa;
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
background: transparent;
pointer-events: none;
}
@@ -387,3 +388,143 @@ body,
display: none;
}
}
/* Moonlit Water is a transparent programme list rather than a framed menu.
The thin rules are intentional typographic separators, not card borders. */
.gift-menu-theme-moonlit-water.gift-menu-overlay {
padding: clamp(8px, 1.3vmin, 16px);
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.gift-menu-copy,
.gift-menu-row > em,
.gift-menu-empty {
filter: brightness(var(--component-font-brightness, 1.3));
}
.gift-menu-theme-moonlit-water .gift-menu-viewport {
height: min(100%, var(--menu-panel-height));
border: 0;
border-top: 1px solid rgba(231, 201, 130, 0.34);
border-bottom: 1px solid rgba(231, 201, 130, 0.26);
border-radius: 0;
outline: 0;
background: transparent;
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-row,
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n + 2),
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n) {
gap: calc(var(--menu-row-height) * 0.12);
padding-inline: calc(var(--menu-row-height) * 0.12);
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.17);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-row::after {
display: none;
}
.gift-menu-theme-moonlit-water .gift-menu-icon {
width: calc(var(--menu-row-height) * 0.54);
height: calc(var(--menu-row-height) * 0.54);
border: 0;
border-radius: 0;
color: #e7c982;
background: transparent;
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-gift-icon img {
filter: saturate(0.75) sepia(0.1) brightness(1.08) drop-shadow(0 0 5px rgba(231, 201, 130, 0.4));
}
.gift-menu-theme-moonlit-water .gift-menu-guard-icon img {
filter: saturate(0.72) sepia(0.08) drop-shadow(0 0 5px rgba(231, 201, 130, 0.38));
}
.gift-menu-theme-moonlit-water .gift-menu-battery-icon i {
border-color: #e7c982;
border-radius: 10%;
background: linear-gradient(to top, rgba(231, 201, 130, 0.88) 0 68%, transparent 68%);
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-battery-icon i::before {
background: #e7c982;
}
.gift-menu-theme-moonlit-water .gift-menu-copy {
gap: calc(var(--menu-row-height) * 0.025);
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
}
.gift-menu-theme-moonlit-water .gift-menu-copy strong {
color: #e7c982;
font-weight: 600;
letter-spacing: 0.09em;
text-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-copy span {
color: #eadfc3;
text-shadow: 0 1px 5px rgba(10, 34, 38, 0.86);
}
.gift-menu-theme-moonlit-water .gift-menu-row em {
inset: 0;
padding: 0.28em 1em;
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.82);
border-radius: 0;
color: #eadfc3;
background: linear-gradient(90deg, transparent, rgba(72, 105, 98, 0.32) 50%, transparent);
box-shadow: none;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.9);
}
.gift-menu-theme-moonlit-water .gift-menu-row.triggered {
border-bottom-color: rgba(231, 201, 130, 0.9);
animation: moonlit-menu-awaken var(--menu-highlight-duration) ease-out both;
}
.gift-menu-theme-moonlit-water .gift-menu-row.triggered::before {
inset: 0;
background: linear-gradient(
90deg,
transparent 14%,
rgba(231, 201, 130, 0.15) 42%,
rgba(244, 235, 205, 0.6) 50%,
rgba(174, 199, 175, 0.16) 59%,
transparent 85%
);
}
.gift-menu-theme-moonlit-water .gift-menu-row-particles i {
background: #e7c982;
}
.gift-menu-theme-moonlit-water .gift-menu-empty {
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.25);
border-radius: 0;
color: #afc2ad;
background: transparent;
}
@keyframes moonlit-menu-awaken {
0% {
filter: brightness(1);
}
14% {
filter: brightness(1.32);
}
100% {
filter: brightness(1);
}
}
+2
View File
@@ -7,6 +7,7 @@ import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftMenuSettings } from './types'
import type { GiftMenuItem, GiftMenuSettings } from './types'
import { typographyVariables } from './typography'
type MenuEnvelope = {
id: string
@@ -265,6 +266,7 @@ export function GiftMenuOverlay({
style={
{
...giftMenuThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--menu-row-height' as string]: `${settings.rowHeight}px`,
['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`,
['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`,
+18
View File
@@ -35,6 +35,24 @@ export const giftMenuThemes: readonly GiftMenuTheme[] = [
vine: '/assets/floral-vine.svg',
},
},
{
id: 'moonlit-water',
nameKey: 'gift_menu.theme.moonlit_water.name',
descriptionKey: 'gift_menu.theme.moonlit_water.description',
className: 'gift-menu-theme-moonlit-water',
palette: {
jade: '#9bb8aa',
cyan: '#d9e3c9',
gold: '#e7c982',
rose: '#dbc6a0',
ink: 'transparent',
},
ornaments: {
divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.svg',
},
},
]
export function getGiftMenuTheme(id: unknown): GiftMenuTheme {
+18
View File
@@ -39,6 +39,24 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
starPulse: 'gift-star-pulse',
},
},
{
id: 'moonlit-water',
nameKey: 'gift.theme.moonlit_water.name',
descriptionKey: 'gift.theme.moonlit_water.description',
className: 'gift-theme-moonlit-water',
palette: {
jade: '#8daea2',
cyan: '#c6d8c6',
gold: '#e7c982',
rose: '#dbc6a0',
night: 'transparent',
},
motion: {
meteor: 'moonlit-offering-sweep',
guardReveal: 'moonlit-guard-reveal',
starPulse: 'moonlit-star-pulse',
},
},
]
export function getGiftEffectTheme(id: unknown): GiftEffectTheme {
+27 -1
View File
@@ -15,6 +15,7 @@ import { getOverlayTheme, normalizeThemeId, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
import { defaultOverlaySettings } from './types'
import type { OverlaySettings } from './types'
import { typographyVariables } from './typography'
type Envelope = {
id: string
@@ -26,7 +27,7 @@ type Envelope = {
}
type LivePayload = {
viewer?: { uid?: string; name?: string }
viewer?: { uid?: string; name?: string; avatarUrl?: string }
text?: string
message?: string
guardName?: string
@@ -243,6 +244,29 @@ function DanmakuBody({ payload }: { payload: LivePayload }) {
)
}
function ViewerAvatar({ viewer }: { viewer: NonNullable<LivePayload['viewer']> }) {
const [failed, setFailed] = useState(false)
const name = viewer.name?.trim() || translate('common.viewer')
const initial = Array.from(name)[0] || '观'
return (
<div className="viewer-avatar" aria-hidden="true">
{viewer.avatarUrl && !failed ? (
<img
src={viewer.avatarUrl}
alt=""
decoding="async"
draggable={false}
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
) : (
<span>{initial}</span>
)}
</div>
)
}
function Card({
item,
settings,
@@ -293,6 +317,7 @@ function Card({
</div>
)}
<div className="copy">
{isDanmaku && <ViewerAvatar viewer={viewer} />}
<b>{viewer.name || translate('common.viewer')}</b>
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
@@ -385,6 +410,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
style={
{
...themeCssVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--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`,
+173 -1
View File
@@ -16,7 +16,7 @@ body,
gap: clamp(4px, 0.8vmin, 8px);
padding: clamp(4px, 0.8vmin, 9px);
color: var(--theme-text);
font-family: 'Noto Serif SC', 'Microsoft YaHei', sans-serif;
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
font-size: var(--song-font-size, clamp(12px, 2.4vmin, 24px));
overflow: hidden;
}
@@ -77,6 +77,14 @@ body,
gap: 0.12em;
}
.song-current-copy,
.song-score,
.song-queue > header,
.song-row,
.song-queue-empty {
filter: brightness(var(--component-font-brightness, 1.3));
}
.song-current-copy small,
.song-score small {
color: var(--theme-compact-user);
@@ -367,3 +375,167 @@ body,
animation: none;
}
}
/* 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. */
.song-overlay.theme-moonlit-water {
gap: clamp(5px, 0.7vmin, 9px);
padding: clamp(8px, 1.4vmin, 16px);
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.theme-moonlit-water .song-current,
.theme-moonlit-water .song-queue {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.theme-moonlit-water .song-current::after,
.theme-moonlit-water .song-queue::after {
display: none;
}
.theme-moonlit-water .song-current {
min-height: min(var(--song-current-min, 56px), 62px);
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);
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;
}
.theme-moonlit-water .song-current-mark::after {
content: '';
display: block;
width: 1.4em;
height: 1px;
margin-top: 0.1em;
background: linear-gradient(90deg, transparent, #e7c982, transparent);
}
.theme-moonlit-water .song-current-copy,
.theme-moonlit-water .song-score {
gap: 0.08em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
}
.theme-moonlit-water .song-current-copy small,
.theme-moonlit-water .song-score small,
.theme-moonlit-water .song-requester,
.theme-moonlit-water .song-queue > header {
color: #afc2ad;
}
.theme-moonlit-water .song-current-copy strong {
color: #eadfc3;
font-size: 1.18em;
font-weight: 600;
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
}
.theme-moonlit-water .song-score b,
.theme-moonlit-water .song-index {
color: #e7c982;
}
.theme-moonlit-water .song-queue > header {
padding: 0.32em 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.24);
letter-spacing: 0.18em;
}
.theme-moonlit-water .song-queue > header b {
padding: 0;
border-radius: 0;
color: #e7c982;
background: transparent;
}
.theme-moonlit-water .song-queue-track {
gap: 0;
padding: 0;
}
.theme-moonlit-water .song-row {
gap: 0.5em;
padding: 0.45em 0.12em;
border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.17);
border-radius: 0;
background: transparent;
box-shadow: none;
animation: moonlit-song-row-insert 0.54s ease-out both;
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 {
display: none;
}
.theme-moonlit-water .song-row strong {
color: #eadfc3;
font-weight: 600;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.74);
}
.theme-moonlit-water .song-particle-layer {
opacity: 0.36;
}
.theme-moonlit-water .song-particle.star {
background: #e7c982;
filter: none;
}
.theme-moonlit-water .song-particle.floret {
background:
radial-gradient(circle at 50% 17%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 82% 50%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 50% 83%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #e7c982 0 18%, transparent 21%);
}
@keyframes moonlit-song-current {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes moonlit-song-row-insert {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: none;
}
}
+2
View File
@@ -8,6 +8,7 @@ import { getOverlayTheme, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
import { defaultSongRequestSettings } from './types'
import type { SongRequestItem, SongRequestSettings } from './types'
import { typographyVariables } from './typography'
type QueueState = {
initialized: boolean
@@ -307,6 +308,7 @@ export function SongRequestOverlay({
style={
{
...themeCssVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--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`,
} as CSSProperties
+2 -1
View File
@@ -1,10 +1,11 @@
import { jadeScrollTheme } from './jadeScroll'
import { moonlitWaterTheme } from './moonlitWater'
import type { OverlayThemeDefinition, OverlayThemeId } from './types'
export type { OverlayThemeDefinition, OverlayThemeId, ThemeParticle } from './types'
/** Ordered registry used by both the control select and OBS renderer. */
export const overlayThemes: readonly OverlayThemeDefinition[] = [jadeScrollTheme]
export const overlayThemes: readonly OverlayThemeDefinition[] = [jadeScrollTheme, moonlitWaterTheme]
const themesById = new Map(overlayThemes.map(theme => [theme.id, theme]))
+44
View File
@@ -0,0 +1,44 @@
import type { OverlayThemeDefinition } from './types'
const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
/**
* A deliberately sparse, text-led theme inspired by moonlight on still water.
*
* Unlike the original glass theme, its surfaces are fully transparent: the
* broadcaster's scene is the background and the overlay contributes only type,
* fine rules, a few drifting petals, and a restrained moonlit shimmer.
*/
export const moonlitWaterTheme: OverlayThemeDefinition = {
id: 'moonlit-water',
nameKey: 'theme.moonlit_water.name',
descriptionKey: 'theme.moonlit_water.description',
className: 'theme-moonlit-water',
palette: {
text: '#eadfc3',
user: '#c7d5b2',
compactUser: '#a8beb0',
accent: '#e7c982',
price: '#f2d79c',
},
surfaces: {
cardBorder: 'transparent',
cardBackground: 'transparent',
},
ornaments: {
variantCount: 4,
particles: ['star', 'floret', 'star', 'floret', 'star', 'star'],
patterns: { divider, cluster, vine },
},
motion: {
arrive: 'moonlit-line-arrive',
sheen: 'moonlit-line-sheen',
featured: 'moonlit-gift-breathe',
float: 'moonlit-drift',
sparkle: 'moonlit-sparkle',
unfurl: 'moonlit-reveal',
railsOpen: 'moonlit-rule-reveal',
},
}
+1 -1
View File
@@ -1,5 +1,5 @@
/** Stable IDs persisted in component settings and emitted over WebSocket. */
export const overlayThemeIds = ['jade-scroll'] as const
export const overlayThemeIds = ['jade-scroll', 'moonlit-water'] as const
export type OverlayThemeId = (typeof overlayThemeIds)[number]
export type ThemeParticle = 'star' | 'floret'
+19 -2
View File
@@ -6,9 +6,12 @@
* server normally returns only `*Configured` flags after initial submission.
*/
import type { OverlayThemeId } from './themes'
import type { FontFamilyId } from './typography'
export type OverlaySettings = {
themeId: OverlayThemeId
fontFamily: FontFamilyId
fontBrightness: number
fontScale: number
showDanmaku: boolean
showEnter: boolean
@@ -30,6 +33,8 @@ export type OverlaySettings = {
export const defaultOverlaySettings: OverlaySettings = {
themeId: 'jade-scroll',
fontFamily: 'fang-song',
fontBrightness: 130,
fontScale: 140,
showDanmaku: true,
showEnter: true,
@@ -52,6 +57,8 @@ export const defaultOverlaySettings: OverlaySettings = {
/** Renderer and anti-spam settings for the built-in song request component. */
export type SongRequestSettings = {
themeId: OverlayThemeId
fontFamily: FontFamilyId
fontBrightness: number
fontScale: number
scrollSpeedPixelsPerSecond: number
edgePauseSeconds: number
@@ -63,6 +70,8 @@ export type SongRequestSettings = {
export const defaultSongRequestSettings: SongRequestSettings = {
themeId: 'jade-scroll',
fontFamily: 'fang-song',
fontBrightness: 130,
fontScale: 100,
scrollSpeedPixelsPerSecond: 28,
edgePauseSeconds: 2,
@@ -71,7 +80,7 @@ export const defaultSongRequestSettings: SongRequestSettings = {
requestCooldownSeconds: 0,
}
export type GiftEffectThemeId = 'jade-starfall'
export type GiftEffectThemeId = 'jade-starfall' | 'moonlit-water'
export type MeteorTierSettings = {
count: number
@@ -82,6 +91,8 @@ export type MeteorTierSettings = {
/** Full-viewport visual settings for gift meteors and guard celebrations. */
export type GiftEffectSettings = {
themeId: GiftEffectThemeId
fontFamily: FontFamilyId
fontBrightness: number
highValueThreshold: number
featuredValueThreshold: number
normal: MeteorTierSettings
@@ -96,6 +107,8 @@ export type GiftEffectSettings = {
export const defaultGiftEffectSettings: GiftEffectSettings = {
themeId: 'jade-starfall',
fontFamily: 'fang-song',
fontBrightness: 130,
highValueThreshold: 10_000,
featuredValueThreshold: 100_000,
normal: { count: 3, size: 88, speed: 560 },
@@ -135,10 +148,12 @@ export type GiftMenuItem = {
description: string
}
export type GiftMenuThemeId = 'jade-banquet'
export type GiftMenuThemeId = 'jade-banquet' | 'moonlit-water'
export type GiftMenuSettings = {
themeId: GiftMenuThemeId
fontFamily: FontFamilyId
fontBrightness: number
items: GiftMenuItem[]
visibleRows: number
rowHeight: number
@@ -151,6 +166,8 @@ export type GiftMenuSettings = {
export const defaultGiftMenuSettings: GiftMenuSettings = {
themeId: 'jade-banquet',
fontFamily: 'fang-song',
fontBrightness: 130,
items: [],
visibleRows: 4,
rowHeight: 88,
+33
View File
@@ -0,0 +1,33 @@
import type { CSSProperties } from 'react'
/** Stable IDs persisted by the backend; each maps to a fixed, injection-safe stack. */
export const fontFamilyIds = ['song', 'fang-song', 'kai'] as const
export type FontFamilyId = (typeof fontFamilyIds)[number]
export const fontFamilies: ReadonlyArray<{ id: FontFamilyId; nameKey: string }> = [
{ id: 'song', nameKey: 'settings.font_family.song' },
{ id: 'fang-song', nameKey: 'settings.font_family.fang_song' },
{ id: 'kai', nameKey: 'settings.font_family.kai' },
]
const fontStacks: Record<FontFamilyId, string> = {
song: '"Source Han Serif SC", "Noto Serif CJK SC", "Noto Serif SC", "Songti SC", STSong, SimSun, serif',
'fang-song': 'FangSong, STFangsong, "FZShuSong-Z01", "Noto Serif SC", "Songti SC", serif',
kai: '"Kaiti SC", STKaiti, KaiTi, "LXGW WenKai", "Noto Serif SC", serif',
}
export function normalizeFontFamilyId(value: unknown): FontFamilyId {
return fontFamilyIds.includes(value as FontFamilyId) ? (value as FontFamilyId) : 'fang-song'
}
export function typographyVariables(
fontFamily: FontFamilyId,
fontBrightness: number,
): CSSProperties {
const brightness = Math.min(180, Math.max(70, Number(fontBrightness) || 130))
return {
['--component-font-family' as string]: fontStacks[normalizeFontFamilyId(fontFamily)],
['--component-font-brightness' as string]: `${brightness / 100}`,
}
}