新主题
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use sha2::{Digest, Sha256};
|
||||
use crate::{
|
||||
credentials::normalize_cookiecloud_host,
|
||||
overlay::{OverlaySettings, OverlayThemeId},
|
||||
typography::FontFamilyId,
|
||||
};
|
||||
|
||||
/// Process-level configuration. Tenant-owned room, CookieCloud and component
|
||||
@@ -127,6 +128,8 @@ struct EmoticonsConfig {
|
||||
#[derive(Deserialize, Default)]
|
||||
struct OverlayFileConfig {
|
||||
theme_id: Option<OverlayThemeId>,
|
||||
font_family: Option<FontFamilyId>,
|
||||
font_brightness: Option<u16>,
|
||||
font_scale: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
@@ -323,6 +326,8 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
let default = OverlaySettings::default();
|
||||
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),
|
||||
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),
|
||||
|
||||
@@ -51,6 +51,10 @@ impl LiveEventKind {
|
||||
pub struct PlatformViewer {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
/// Optional public avatar supplied by the live platform. Older event
|
||||
/// formats omit it, so renderers must keep a local fallback.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
@@ -331,6 +335,7 @@ mod tests {
|
||||
PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
components::{ComponentDefinition, ComponentError, EventSubscription},
|
||||
domain::LiveEventKind,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const GIFT_EFFECT_KIND: &str = "gift_effect";
|
||||
@@ -21,6 +22,7 @@ pub const GIFT_EFFECT_NAME: &str = "礼物星雨";
|
||||
pub enum GiftEffectThemeId {
|
||||
#[default]
|
||||
JadeStarfall,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
@@ -45,6 +47,10 @@ impl MeteorTierSettings {
|
||||
pub struct GiftEffectSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: GiftEffectThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
pub high_value_threshold: i64,
|
||||
pub featured_value_threshold: i64,
|
||||
pub normal: MeteorTierSettings,
|
||||
@@ -61,6 +67,8 @@ impl Default for GiftEffectSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: GiftEffectThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
high_value_threshold: 10_000,
|
||||
featured_value_threshold: 100_000,
|
||||
normal: MeteorTierSettings {
|
||||
@@ -90,6 +98,7 @@ impl Default for GiftEffectSettings {
|
||||
impl GiftEffectSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.high_value_threshold = self.high_value_threshold.max(0);
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.featured_value_threshold =
|
||||
self.featured_value_threshold.max(self.high_value_threshold);
|
||||
self.normal = self.normal.sanitize();
|
||||
@@ -155,11 +164,13 @@ mod tests {
|
||||
let definition = GiftEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["normal"]["count"] = Value::from(0);
|
||||
settings["fontBrightness"] = Value::from(999);
|
||||
settings["featured"]["size"] = Value::from(9_999);
|
||||
settings["highValueThreshold"] = Value::from(50_000);
|
||||
settings["featuredValueThreshold"] = Value::from(10_000);
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["normal"]["count"], 1);
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["featured"]["size"], 480);
|
||||
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
|
||||
}
|
||||
@@ -175,4 +186,13 @@ mod tests {
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["themeId"] = Value::from("moonlit-water");
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::{
|
||||
},
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload},
|
||||
overlay::normalize_image_url,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const GIFT_MENU_KIND: &str = "gift_menu";
|
||||
@@ -28,6 +29,7 @@ const MAX_MENU_ITEMS: usize = 100;
|
||||
pub enum GiftMenuThemeId {
|
||||
#[default]
|
||||
JadeBanquet,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
|
||||
@@ -73,6 +75,10 @@ pub struct GiftMenuSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: GiftMenuThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
#[serde(default)]
|
||||
pub items: Vec<GiftMenuItem>,
|
||||
pub visible_rows: u8,
|
||||
pub row_height: u16,
|
||||
@@ -87,6 +93,8 @@ impl Default for GiftMenuSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: GiftMenuThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
items: Vec::new(),
|
||||
visible_rows: 4,
|
||||
row_height: 88,
|
||||
@@ -117,6 +125,7 @@ impl GiftMenuSettings {
|
||||
}
|
||||
}
|
||||
self.visible_rows = self.visible_rows.clamp(1, 20);
|
||||
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.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
|
||||
@@ -314,6 +323,7 @@ mod tests {
|
||||
PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +373,7 @@ mod tests {
|
||||
row_height: 1,
|
||||
scroll_speed_pixels_per_second: u16::MAX,
|
||||
font_scale: 1,
|
||||
font_brightness: 1,
|
||||
..GiftMenuSettings::default()
|
||||
};
|
||||
let sanitized = definition
|
||||
@@ -372,6 +383,7 @@ mod tests {
|
||||
assert_eq!(sanitized["rowHeight"], 44);
|
||||
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
|
||||
assert_eq!(sanitized["fontScale"], 50);
|
||||
assert_eq!(sanitized["fontBrightness"], 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -507,4 +519,13 @@ mod tests {
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GiftMenuDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["themeId"] = Value::from("moonlit-water");
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,7 +993,11 @@ async fn component_test_event(
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
let component = state.repository.get_component(session.user.id, id).await?;
|
||||
let viewer = |uid: String, name: String| PlatformViewer { uid, name };
|
||||
let viewer = |uid: String, name: String| PlatformViewer {
|
||||
uid,
|
||||
name,
|
||||
avatar_url: None,
|
||||
};
|
||||
let payload = match body {
|
||||
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
|
||||
viewer: viewer(uid, name),
|
||||
|
||||
@@ -22,3 +22,4 @@ pub mod rate_limit;
|
||||
pub mod realtime;
|
||||
pub mod repository;
|
||||
pub mod song_request;
|
||||
pub mod typography;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! modeled by `libilibili` are interpreted only inside this provider boundary;
|
||||
//! unknown events expose sanitized command names, never raw packets or secrets.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use libilibili::{
|
||||
@@ -491,6 +491,7 @@ fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
|
||||
viewer: PlatformViewer {
|
||||
uid: message.uid.to_string(),
|
||||
name: message.username,
|
||||
avatar_url: None,
|
||||
},
|
||||
name: message.gift_name,
|
||||
quantity: bounded_i32(message.num).max(1),
|
||||
@@ -534,6 +535,7 @@ fn normalize_danmaku(message: &DanmuMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: danmaku_avatar(message),
|
||||
},
|
||||
emoticons: parse_danmaku_emoticons(message, &text),
|
||||
text,
|
||||
@@ -629,6 +631,7 @@ fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
// Blind-box SEND_GIFT events describe the revealed prize in the
|
||||
// ordinary gift fields. The `blind_gift.original_*` fields identify
|
||||
@@ -674,6 +677,7 @@ fn normalize_combo(message: &ComboSendMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
name: message
|
||||
.data
|
||||
@@ -701,6 +705,7 @@ fn normalize_like(message: &LikeClickMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -726,6 +731,7 @@ fn normalize_superchat(message: &SuperChatMessage) -> Option<ProviderEvent> {
|
||||
viewer: PlatformViewer {
|
||||
uid: uid.to_string(),
|
||||
name,
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
message: message.data.message.clone(),
|
||||
price: bounded_i64(message.data.price).unwrap_or(i64::MAX),
|
||||
@@ -737,6 +743,7 @@ fn normalize_interaction(message: &InteractWordMessage) -> Option<ProviderEvent>
|
||||
let viewer = PlatformViewer {
|
||||
uid: message.data.uid.to_string(),
|
||||
name: message.data.uname.clone(),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
};
|
||||
match message.data.msg_type {
|
||||
1 => Some(ProviderEvent::Enter { viewer }),
|
||||
@@ -761,16 +768,73 @@ fn sanitized_command(command: Option<&str>) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recover the public avatar from both legacy and current Bilibili sender
|
||||
/// layouts. `libilibili` deliberately preserves unknown DANMU_MSG entries, so
|
||||
/// this adapter can adopt new sender envelopes without exposing raw packets to
|
||||
/// the rest of the application.
|
||||
fn danmaku_avatar(message: &DanmuMessage) -> Option<String> {
|
||||
message
|
||||
.metadata
|
||||
.extra
|
||||
.values()
|
||||
.chain(message.sender.extra.values())
|
||||
.chain(message.unknown_info.values())
|
||||
.chain(message.extra.values())
|
||||
.find_map(avatar_from_value)
|
||||
.or_else(|| {
|
||||
message.extension.as_ref().and_then(|extension| {
|
||||
avatar_from_extra(&extension.extra).or_else(|| {
|
||||
extension
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
||||
.as_ref()
|
||||
.and_then(avatar_from_value)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn avatar_from_extra(extra: &HashMap<String, Value>) -> Option<String> {
|
||||
["face", "avatar", "avatar_url"]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
extra
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_image_url)
|
||||
})
|
||||
.or_else(|| extra.values().find_map(avatar_from_value))
|
||||
}
|
||||
|
||||
fn avatar_from_value(value: &Value) -> Option<String> {
|
||||
[
|
||||
"/sender_uinfo/base/face",
|
||||
"/user/base/face",
|
||||
"/user_info/face",
|
||||
"/base/face",
|
||||
"/face",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|path| {
|
||||
value
|
||||
.pointer(path)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_image_url)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
let command = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
|
||||
let data = raw.get("data").unwrap_or(raw);
|
||||
let viewer = |uid: &Value, name: &Value| {
|
||||
let viewer = |uid: &Value, name: &Value, avatar: Option<&Value>| {
|
||||
Some(PlatformViewer {
|
||||
uid: uid
|
||||
.as_i64()
|
||||
.map(|id| id.to_string())
|
||||
.or_else(|| uid.as_str().map(str::to_owned))?,
|
||||
name: name.as_str()?.to_owned(),
|
||||
avatar_url: avatar.and_then(Value::as_str).and_then(normalize_image_url),
|
||||
})
|
||||
};
|
||||
let data_viewer = |value: &Value| {
|
||||
@@ -780,6 +844,10 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
.get("uname")
|
||||
.or_else(|| value.pointer("/sender_uinfo/base/name"))
|
||||
.or_else(|| value.pointer("/user_info/uname"))?,
|
||||
value
|
||||
.get("face")
|
||||
.or_else(|| value.pointer("/sender_uinfo/base/face"))
|
||||
.or_else(|| value.pointer("/user_info/face")),
|
||||
)
|
||||
};
|
||||
match command.as_str() {
|
||||
@@ -1239,6 +1307,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_avatar_from_current_danmaku_sender_envelope() {
|
||||
let mut metadata = vec![Value::Null; 16];
|
||||
metadata[15] = json!({
|
||||
"user": {
|
||||
"base": {
|
||||
"face": "//i0.hdslb.com/bfs/face/avatar.jpg"
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut info = vec![Value::Null; 3];
|
||||
info[0] = Value::Array(metadata);
|
||||
info[1] = json!("晚上好");
|
||||
info[2] = json!([12345, "观众"]);
|
||||
let message = normalize_command(parse_command(json!({
|
||||
"cmd": "DANMU_MSG",
|
||||
"info": info
|
||||
})))
|
||||
.unwrap();
|
||||
|
||||
match message {
|
||||
ProviderEvent::Danmaku { viewer, .. } => assert_eq!(
|
||||
viewer.avatar_url.as_deref(),
|
||||
Some("https://i0.hdslb.com/bfs/face/avatar.jpg")
|
||||
),
|
||||
_ => panic!("expected danmaku"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_events_do_not_forward_raw_payload() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
|
||||
@@ -12,6 +12,8 @@ use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness};
|
||||
|
||||
/// Stable identifier for a renderer theme.
|
||||
///
|
||||
/// This enum is deliberately shared by defaults, validation and serialization:
|
||||
@@ -23,6 +25,7 @@ use uuid::Uuid;
|
||||
pub enum OverlayThemeId {
|
||||
#[default]
|
||||
JadeScroll,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -31,6 +34,10 @@ pub struct OverlaySettings {
|
||||
/// Visual theme selected for this tenant-owned component instance.
|
||||
#[serde(default)]
|
||||
pub theme_id: OverlayThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
pub show_danmaku: bool,
|
||||
@@ -59,6 +66,8 @@ impl Default for OverlaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: OverlayThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
font_scale: default_font_scale(),
|
||||
show_danmaku: true,
|
||||
show_enter: true,
|
||||
@@ -83,6 +92,7 @@ impl Default for OverlaySettings {
|
||||
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.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);
|
||||
@@ -546,6 +556,7 @@ mod tests {
|
||||
fn settings_are_bounded_before_persistence() {
|
||||
let settings = OverlaySettings {
|
||||
font_scale: 999,
|
||||
font_brightness: 1,
|
||||
max_visible: 99,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
@@ -558,6 +569,7 @@ mod tests {
|
||||
}
|
||||
.sanitize();
|
||||
assert_eq!(settings.font_scale, 300);
|
||||
assert_eq!(settings.font_brightness, 70);
|
||||
assert_eq!(settings.max_visible, 12);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
@@ -580,12 +592,16 @@ mod tests {
|
||||
let object = value.as_object_mut().expect("settings object");
|
||||
object.insert("title".into(), json!("旧版弹幕栏标题"));
|
||||
object.remove("fontScale");
|
||||
object.remove("fontFamily");
|
||||
object.remove("fontBrightness");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
object.remove("themeId");
|
||||
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
|
||||
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
|
||||
assert_eq!(settings.font_family, FontFamilyId::FangSong);
|
||||
assert_eq!(settings.font_brightness, 130);
|
||||
assert_eq!(settings.font_scale, 140);
|
||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
@@ -599,4 +615,16 @@ mod tests {
|
||||
let error = serde_json::from_value::<OverlaySettings>(value).expect_err("unknown theme");
|
||||
assert!(error.to_string().contains("unknown variant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_a_valid_persisted_identifier() {
|
||||
let mut value = json!(OverlaySettings::default());
|
||||
value["themeId"] = json!("moonlit-water");
|
||||
let settings: OverlaySettings = serde_json::from_value(value).expect("moonlit theme");
|
||||
assert_eq!(settings.theme_id, OverlayThemeId::MoonlitWater);
|
||||
assert_eq!(
|
||||
serde_json::to_value(settings).unwrap()["themeId"],
|
||||
"moonlit-water"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +402,7 @@ mod tests {
|
||||
viewer: PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
text: "晚上好".into(),
|
||||
segments: Vec::new(),
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload, PlatformViewer},
|
||||
overlay::OverlayThemeId,
|
||||
realtime::EventHub,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const SONG_REQUEST_KIND: &str = "song_request";
|
||||
@@ -33,6 +34,10 @@ const SNAPSHOT_PAGE_SIZE: usize = 100;
|
||||
pub struct SongRequestSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: OverlayThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
#[serde(default = "default_scroll_speed")]
|
||||
@@ -54,6 +59,8 @@ impl Default for SongRequestSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: OverlayThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
font_scale: default_font_scale(),
|
||||
scroll_speed_pixels_per_second: default_scroll_speed(),
|
||||
edge_pause_seconds: default_edge_pause(),
|
||||
@@ -67,6 +74,7 @@ impl Default for SongRequestSettings {
|
||||
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.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);
|
||||
@@ -1022,6 +1030,7 @@ mod tests {
|
||||
|
||||
let bounded = SongRequestSettings {
|
||||
font_scale: 999,
|
||||
font_brightness: u16::MAX,
|
||||
scroll_speed_pixels_per_second: 0,
|
||||
edge_pause_seconds: 200,
|
||||
max_queue_size: u32::MAX,
|
||||
@@ -1031,6 +1040,7 @@ mod tests {
|
||||
}
|
||||
.sanitize();
|
||||
assert_eq!(bounded.font_scale, 250);
|
||||
assert_eq!(bounded.font_brightness, 180);
|
||||
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
|
||||
assert_eq!(bounded.edge_pause_seconds, 15);
|
||||
assert_eq!(bounded.max_queue_size, 10_000);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Shared, validated typography settings for all built-in OBS components.
|
||||
//!
|
||||
//! Font IDs map to fixed frontend font stacks rather than accepting arbitrary
|
||||
//! CSS. This keeps settings portable between OBS hosts and prevents untrusted
|
||||
//! component settings from becoming a CSS injection boundary.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FontFamilyId {
|
||||
Song,
|
||||
#[default]
|
||||
FangSong,
|
||||
Kai,
|
||||
}
|
||||
|
||||
pub const fn default_font_brightness() -> u16 {
|
||||
130
|
||||
}
|
||||
|
||||
pub fn sanitize_font_brightness(value: u16) -> u16 {
|
||||
value.clamp(70, 180)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn font_ids_are_stable_and_brightness_is_bounded() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&FontFamilyId::FangSong).unwrap(),
|
||||
"\"fang-song\""
|
||||
);
|
||||
assert_eq!(sanitize_font_brightness(1), 70);
|
||||
assert_eq!(sanitize_font_brightness(u16::MAX), 180);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user