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