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