update UI

This commit is contained in:
2026-08-11 09:19:13 -07:00
parent 97a3f1be48
commit 4eda93cf24
31 changed files with 952 additions and 95 deletions
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 72" aria-hidden="true">
<!-- Repository-original transparent lotus silhouette used as a CSS mask. -->
<g fill="#000">
<path d="M48 39C36 28 35 15 48 3c13 12 12 25 0 36Z" />
<path d="M43 43C27 38 19 26 24 10c16 5 23 17 19 33Z" opacity=".9" />
<path d="M53 43c16-5 24-17 19-33-16 5-23 17-19 33Z" opacity=".9" />
<path d="M39 48C21 49 9 40 7 24c17-1 28 8 32 24Z" opacity=".75" />
<path d="M57 48c18 1 30-8 32-24-17-1-28 8-32 24Z" opacity=".75" />
<path d="M48 62C29 62 16 56 10 45c13 4 25 4 38-1 13 5 25 5 38 1-6 11-19 17-38 17Z" />
<path d="M17 67c20-5 42-5 62 0-21 3-41 3-62 0Z" opacity=".62" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 697 B

+58
View File
@@ -0,0 +1,58 @@
/**
* Shared Moonlit Water edge ornament.
*
* Keeping the artwork inline lets the persisted decoration setting control
* real SVG stroke width. A raster image or CSS-scaled mask would only distort
* the motif and could not make its calligraphic lines genuinely heavier.
*/
function EdgeArtwork({ position }: { position: 'top' | 'bottom' }) {
return (
<svg
className={`theme-edge-art theme-edge-${position}`}
viewBox="0 0 1200 56"
preserveAspectRatio="none"
aria-hidden="true"
>
<g className="theme-edge-rule">
<path d="M12 30h338c52 0 86-8 116-24-13 13-13 29 0 42-30-16-64-24-116-24H12" />
<path d="M1188 30H850c-52 0-86-8-116-24 13 13 13 29 0 42 30-16 64-24 116-24h338" />
</g>
<g className="theme-edge-water">
<path d="M38 38c35-14 70-14 105 0s70 14 105 0" />
<path d="M1162 38c-35-14-70-14-105 0s-70 14-105 0" />
</g>
<g className="theme-edge-cloud">
<path d="M192 27c10-14 31-14 40 0 8-10 25-8 29 4-22 8-53 8-76 0 1-2 4-3 7-4Z" />
<path d="M1008 27c-10-14-31-14-40 0-8-10-25-8-29 4 22 8 53 8 76 0-1-2-4-3-7-4Z" />
</g>
<g className="theme-edge-leaves">
<path d="M485 28c13-14 27-17 42-9-10 13-24 16-42 9Z" />
<path d="M715 28c-13-14-27-17-42-9 10 13 24 16 42 9Z" />
</g>
<g className="theme-edge-lotus">
<path d="M600 34c-9-10-9-22 0-32 9 10 9 22 0 32Z" />
<path d="M595 36c-15-5-22-16-19-29 15 5 22 15 19 29Z" />
<path d="M605 36c15-5 22-16 19-29-15 5-22 15-19 29Z" />
<path d="M590 40c-17 0-28-7-32-20 16 0 27 7 32 20Z" />
<path d="M610 40c17 0 28-7 32-20-16 0-27 7-32 20Z" />
<path d="M600 49c-18 0-31-5-39-15 14 4 27 3 39-3 12 6 25 7 39 3-8 10-21 15-39 15Z" />
</g>
<g className="theme-edge-dots">
<circle cx="92" cy="27" r="3.2" />
<circle cx="1108" cy="27" r="3.2" />
<path d="m536 27 4-7 4 7-4 7Z" />
<path d="m664 27-4-7-4 7 4 7Z" />
</g>
</svg>
)
}
/** Decorative chrome is ignored by assistive technology and never captures input. */
export function ThemeEdges() {
return (
<div className="theme-edge-set" aria-hidden="true">
<EdgeArtwork position="top" />
<EdgeArtwork position="bottom" />
</div>
)
}
+1 -1
View File
@@ -51,7 +51,7 @@ function AuthShell({
) )
} }
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) { export function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const { t } = useI18n() const { t } = useI18n()
const source = useMemo(() => { const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl if (enrollment.qrDataUrl) return enrollment.qrDataUrl
+46 -3
View File
@@ -374,7 +374,17 @@ body,
} }
.wall { .wall {
justify-content: flex-start; height: 100%;
min-height: 0;
justify-content: flex-end;
overflow: hidden;
}
.wall > .cards {
/* Keep the chronological stack pinned to the bottom. Once it is taller
than the OBS viewport, its oldest rows overflow above the clipping edge
while each newly appended row remains visible at the bottom. */
flex: 0 0 auto;
} }
.card { .card {
@@ -804,6 +814,22 @@ body,
background 0.24s ease; background 0.24s ease;
} }
.danmaku-lotus {
position: absolute;
z-index: 1;
top: 50%;
right: clamp(8px, 1.6vw, 18px);
width: clamp(26px, 7%, 44px);
aspect-ratio: 4 / 3;
pointer-events: none;
background: linear-gradient(145deg, var(--theme-accent), var(--theme-user));
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--theme-accent) 48%, transparent));
opacity: 0.62;
transform: translateY(-50%);
-webkit-mask: var(--theme-pattern-lotus) center / contain no-repeat;
mask: var(--theme-pattern-lotus) center / contain no-repeat;
}
.card.danmaku.expanded { .card.danmaku.expanded {
min-height: 92px; min-height: 92px;
padding: 14px; padding: 14px;
@@ -826,7 +852,7 @@ body,
inset-inline: -1px; inset-inline: -1px;
z-index: 2; z-index: 2;
pointer-events: none; pointer-events: none;
border-inline: 4px solid rgba(123, 238, 214, 0.72); border-inline: var(--component-decoration-rail, 4px) solid rgba(123, 238, 214, 0.72);
border-radius: 11px; border-radius: 11px;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
@@ -901,6 +927,11 @@ body,
white-space: normal; white-space: normal;
} }
.card.danmaku.expanded .copy,
.card.danmaku.compact .copy {
padding-inline-end: clamp(38px, 8%, 58px);
}
.short .cards .card:nth-child(n + 4) { .short .cards .card:nth-child(n + 4) {
display: flex; display: flex;
} }
@@ -1071,6 +1102,13 @@ body,
width: 100%; width: 100%;
} }
.theme-moonlit-water .danmaku-lotus {
width: clamp(30px, 8%, 52px);
background: linear-gradient(145deg, #e8d59a, #9cb7a5 72%);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.34));
opacity: 0.82;
}
.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;
@@ -1099,7 +1137,7 @@ body,
height: clamp(38px, 2.3em, 56px); height: clamp(38px, 2.3em, 56px);
overflow: hidden; overflow: hidden;
place-items: center; place-items: center;
border: 1px solid rgba(231, 201, 130, 0.58); border: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.58);
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 36% 28%, rgba(224, 233, 205, 0.32), rgba(22, 72, 76, 0.5)); background: radial-gradient(circle at 36% 28%, rgba(224, 233, 205, 0.32), rgba(22, 72, 76, 0.5));
color: #d2b976; color: #d2b976;
@@ -1190,6 +1228,11 @@ body,
height: clamp(32px, 1.9em, 44px); height: clamp(32px, 1.9em, 44px);
} }
.theme-moonlit-water .card.danmaku.expanded .copy,
.theme-moonlit-water .card.danmaku.compact .copy {
padding-inline-end: clamp(42px, 9%, 66px);
}
.theme-moonlit-water .danmaku-emoticon { .theme-moonlit-water .danmaku-emoticon {
filter: drop-shadow(0 1px 3px rgba(10, 34, 38, 0.56)); filter: drop-shadow(0 1px 3px rgba(10, 34, 38, 0.56));
} }
+241
View File
@@ -19,11 +19,14 @@ import {
normalizeGiftEffectSettings, normalizeGiftEffectSettings,
normalizeGiftMenuSettings, normalizeGiftMenuSettings,
normalizeInvitations, normalizeInvitations,
normalizeEnrollment,
normalizeRecoveryCodes,
normalizeSettings, normalizeSettings,
normalizeSongRequestPage, normalizeSongRequestPage,
normalizeSongRequestSettings, normalizeSongRequestSettings,
normalizeSource, normalizeSource,
} from './api' } from './api'
import { TotpQr } from './auth'
import { Overlay } from './overlay' import { Overlay } from './overlay'
import { GiftEffectOverlay } from './giftEffect' import { GiftEffectOverlay } from './giftEffect'
import { GiftMenuOverlay } from './giftMenu' import { GiftMenuOverlay } from './giftMenu'
@@ -56,6 +59,7 @@ import type {
SongRequestItem, SongRequestItem,
SongRequestPage, SongRequestPage,
SongRequestSettings, SongRequestSettings,
TotpEnrollment,
} from './types' } from './types'
const previewPresets = [ const previewPresets = [
@@ -115,6 +119,32 @@ function TypographySettingsFields({
) )
} }
/** Shared percentage control for theme borders, dividers, and edge ornaments. */
function DecorationLineWeightField({
value,
onChange,
}: {
value: number
onChange: (value: number) => void
}) {
return (
<label>
<span>
{translate('settings.decoration_line_weight')} <output>{value}%</output>
</span>
<input
type="range"
min="50"
max="300"
step="10"
value={value}
onChange={event => onChange(+event.target.value)}
/>
<small>{translate('settings.decoration_line_weight_description')}</small>
</label>
)
}
type TextColorField = { type TextColorField = {
labelKey: string labelKey: string
color: string | null color: string | null
@@ -341,6 +371,10 @@ function SettingsEditor({
]} ]}
/> />
<div className="slider-grid"> <div className="slider-grid">
<DecorationLineWeightField
value={settings.decorationLineWeight}
onChange={value => edit('decorationLineWeight', value)}
/>
<label> <label>
<span> <span>
{translate('settings.font_size')} <output>{settings.fontScale}%</output> {translate('settings.font_size')} <output>{settings.fontScale}%</output>
@@ -571,6 +605,10 @@ function SongRequestSettingsEditor({
]} ]}
/> />
<div className="slider-grid"> <div className="slider-grid">
<DecorationLineWeightField
value={settings.decorationLineWeight}
onChange={value => edit('decorationLineWeight', value)}
/>
<label> <label>
<span> <span>
{translate('settings.font_size')} <output>{settings.fontScale}%</output> {translate('settings.font_size')} <output>{settings.fontScale}%</output>
@@ -1196,6 +1234,10 @@ function GiftMenuSettingsEditor({
</div> </div>
<div className="slider-grid gift-menu-renderer-settings"> <div className="slider-grid gift-menu-renderer-settings">
<DecorationLineWeightField
value={settings.decorationLineWeight}
onChange={value => edit('decorationLineWeight', value)}
/>
<label> <label>
<span> <span>
{translate( {translate(
@@ -1490,6 +1532,204 @@ function SourceEditor({
) )
} }
/** Session-protected TOTP replacement with one-time QR and recovery material. */
function TotpResetPanel() {
const [proof, setProof] = useState('')
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
const [enrollment, setEnrollment] = useState<TotpEnrollment>()
const [newCode, setNewCode] = useState('')
const [recoveryCodes, setRecoveryCodes] = useState<string[]>()
const [flash, setFlash] = useState<Flash>()
const [busy, setBusy] = useState(false)
usePwaUpdateBlocker(
'totp-reset',
translate('security.totp_reset_blocker'),
busy || Boolean(proof || enrollment || newCode || recoveryCodes),
)
const start = async (event: FormEvent) => {
event.preventDefault()
setBusy(true)
setFlash(undefined)
try {
const payload = await api<unknown>(
'/api/v1/auth/totp/reset/start',
json('POST', { code: proof }),
)
const next = normalizeEnrollment(payload)
if (!next.enrollmentToken) throw new Error(translate('security.totp_reset_missing_id'))
setProof('')
setEnrollment(next)
} catch (reason) {
setFlash({
kind: 'error',
text: errorMessage(reason, translate('security.totp_reset_start_failed')),
})
} finally {
setBusy(false)
}
}
const confirm = async (event: FormEvent) => {
event.preventDefault()
if (!enrollment) return
setBusy(true)
setFlash(undefined)
try {
const payload = await api<unknown>(
'/api/v1/auth/totp/reset/confirm',
json('POST', {
enrollmentToken: enrollment.enrollmentToken,
code: newCode,
}),
)
setNewCode('')
setEnrollment(undefined)
setRecoveryCodes(normalizeRecoveryCodes(payload))
} catch (reason) {
setFlash({
kind: 'error',
text: errorMessage(reason, translate('security.totp_reset_confirm_failed')),
})
} finally {
setBusy(false)
}
}
const cancel = () => {
setEnrollment(undefined)
setNewCode('')
setFlash({ kind: 'success', text: translate('security.totp_reset_cancelled') })
}
const recoveryText = recoveryCodes?.join('\n') ?? ''
const downloadRecoveryCodes = () => {
const blob = new Blob(
[
`${translate('auth.recovery_file_title')}\n`,
`${translate('auth.recovery_file_warning')}\n\n`,
recoveryText,
'\n',
],
{ type: 'text/plain;charset=utf-8' },
)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'live-component-recovery-codes.txt'
anchor.click()
URL.revokeObjectURL(url)
}
return (
<Panel title={translate('security.title')} description={translate('security.description')}>
<FlashMessage flash={flash} />
{recoveryCodes ? (
<div>
<div className="notice warning">{translate('security.recovery_replaced')}</div>
<div className="recovery-grid">
{recoveryCodes.map(code => (
<code key={code}>{code}</code>
))}
</div>
<div className="form-actions">
<button
type="button"
className="secondary"
onClick={() => void copyToClipboard(recoveryText)}
>
{translate('auth.copy_all')}
</button>
<button type="button" className="secondary" onClick={downloadRecoveryCodes}>
{translate('auth.download_text')}
</button>
<button
type="button"
onClick={() => {
setRecoveryCodes(undefined)
setFlash({ kind: 'success', text: translate('security.totp_reset_complete') })
}}
>
{translate('auth.recovery_saved')}
</button>
</div>
</div>
) : enrollment ? (
<div>
<div className="notice warning">{translate('security.old_totp_still_active')}</div>
<TotpQr enrollment={enrollment} />
<form className="stack-form compact-form" onSubmit={confirm}>
<label>
{translate('security.new_totp_code')}
<input
required
autoFocus
className="otp-input"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={newCode}
onChange={event => setNewCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</label>
<div className="form-actions">
<button disabled={busy || newCode.length !== 6}>
{busy ? translate('auth.confirming') : translate('security.confirm_totp_reset')}
</button>
<button type="button" className="secondary" disabled={busy} onClick={cancel}>
{translate('security.cancel_totp_reset')}
</button>
</div>
</form>
</div>
) : (
<form className="stack-form compact-form" onSubmit={start}>
<div className="notice warning">{translate('security.totp_reset_warning')}</div>
<label>
{useRecoveryCode
? translate('auth.recovery_code')
: translate('security.current_totp_code')}
<input
required
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
inputMode={useRecoveryCode ? 'text' : 'numeric'}
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
maxLength={useRecoveryCode ? 64 : 6}
placeholder={useRecoveryCode ? translate('auth.recovery_placeholder') : '000000'}
value={proof}
onChange={event =>
setProof(
useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6),
)
}
/>
</label>
<button
type="button"
className="inline-link"
onClick={() => {
setUseRecoveryCode(current => !current)
setProof('')
}}
>
{useRecoveryCode
? translate('auth.use_totp')
: translate('security.use_recovery_for_reset')}
</button>
<button disabled={busy || !proof.trim()}>
{busy ? translate('security.preparing_totp') : translate('security.start_totp_reset')}
</button>
</form>
)}
</Panel>
)
}
type TokenState = { type TokenState = {
publicId: string publicId: string
configured: boolean configured: boolean
@@ -2239,6 +2479,7 @@ export function AccountLiveSourcePage({
<FlashMessage flash={flash} /> <FlashMessage flash={flash} />
{loading && <div className="loading-panel jade-panel">{translate('account.loading')}</div>} {loading && <div className="loading-panel jade-panel">{translate('account.loading')}</div>}
{source && <SourceEditor source={source} onSaved={setSource} />} {source && <SourceEditor source={source} onSaved={setSource} />}
<TotpResetPanel />
</div> </div>
</ControlLayout> </ControlLayout>
) )
+7 -5
View File
@@ -24,7 +24,8 @@ body,
height: min(100%, var(--menu-panel-height)); height: min(100%, var(--menu-panel-height));
min-height: min(100%, var(--menu-row-height)); min-height: min(100%, var(--menu-row-height));
overflow: hidden; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--menu-jade) 38%, transparent); border: var(--component-decoration-line, 1px) solid
color-mix(in srgb, var(--menu-jade) 38%, transparent);
border-radius: clamp(10px, 1.5vmin, 22px); border-radius: clamp(10px, 1.5vmin, 22px);
outline: 1px solid rgba(255, 227, 142, 0.07); outline: 1px solid rgba(255, 227, 142, 0.07);
outline-offset: -4px; outline-offset: -4px;
@@ -71,7 +72,8 @@ body,
gap: calc(var(--menu-row-height) * 0.13); gap: calc(var(--menu-row-height) * 0.13);
overflow: hidden; overflow: hidden;
padding: calc(var(--menu-row-height) * 0.1) calc(var(--menu-row-height) * 0.22); padding: calc(var(--menu-row-height) * 0.1) calc(var(--menu-row-height) * 0.22);
border: 1px solid color-mix(in srgb, var(--menu-jade) 36%, transparent); border: var(--component-decoration-line, 1px) solid
color-mix(in srgb, var(--menu-jade) 36%, transparent);
border-radius: calc(var(--menu-row-height) * 0.17); border-radius: calc(var(--menu-row-height) * 0.17);
background: background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(7, 63, 66, 0.72) 58%, rgba(3, 27, 41, 0.9)), linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(7, 63, 66, 0.72) 58%, rgba(3, 27, 41, 0.9)),
@@ -438,7 +440,7 @@ body,
gap: calc(var(--menu-row-height) * 0.12); gap: calc(var(--menu-row-height) * 0.12);
padding-inline: calc(var(--menu-row-height) * 0.12); padding-inline: calc(var(--menu-row-height) * 0.12);
border: 0; border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.17); border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.17);
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
@@ -498,7 +500,7 @@ body,
inset: 0; inset: 0;
padding: 0.28em 1em; padding: 0.28em 1em;
border: 0; border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.82); border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.82);
border-radius: 0; border-radius: 0;
color: #eadfc3; color: #eadfc3;
background: linear-gradient(90deg, transparent, rgba(72, 105, 98, 0.32) 50%, transparent); background: linear-gradient(90deg, transparent, rgba(72, 105, 98, 0.32) 50%, transparent);
@@ -529,7 +531,7 @@ body,
.gift-menu-theme-moonlit-water .gift-menu-empty { .gift-menu-theme-moonlit-water .gift-menu-empty {
border: 0; border: 0;
border-bottom: 1px solid rgba(231, 201, 130, 0.25); border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.25);
border-radius: 0; border-radius: 0;
color: #afc2ad; color: #afc2ad;
background: transparent; background: transparent;
+4
View File
@@ -7,6 +7,7 @@ import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream' import type { ComponentStream } from './stream'
import { defaultGiftMenuSettings } from './types' import { defaultGiftMenuSettings } from './types'
import type { GiftMenuItem, GiftMenuSettings } from './types' import type { GiftMenuItem, GiftMenuSettings } from './types'
import { ThemeEdges } from './ThemeEdges'
import { typographyVariables } from './typography' import { typographyVariables } from './typography'
type MenuEnvelope = { type MenuEnvelope = {
@@ -365,6 +366,8 @@ export function GiftMenuOverlay({
{ {
...giftMenuThemeVariables(theme), ...giftMenuThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness), ...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--menu-row-height' as string]: `${settings.rowHeight}px`, ['--menu-row-height' as string]: `${settings.rowHeight}px`,
['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`, ['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`,
['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`, ['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`,
@@ -379,6 +382,7 @@ export function GiftMenuOverlay({
} }
data-connection={stream?.connection ?? 'idle'} data-connection={stream?.connection ?? 'idle'}
> >
<ThemeEdges />
<section <section
className="gift-menu-viewport" className="gift-menu-viewport"
ref={viewport} ref={viewport}
+24 -17
View File
@@ -16,6 +16,7 @@ import type { OverlayThemeDefinition } from './themes'
import { defaultOverlaySettings } from './types' import { defaultOverlaySettings } from './types'
import type { OverlaySettings } from './types' import type { OverlaySettings } from './types'
import { typographyVariables } from './typography' import { typographyVariables } from './typography'
import { ThemeEdges } from './ThemeEdges'
type Envelope = { type Envelope = {
id: string id: string
@@ -170,18 +171,19 @@ function useEvents(preview: boolean, stream?: ComponentStream) {
chooseDecorVariant( chooseDecorVariant(
`${envelope.type}:${key}`, `${envelope.type}:${key}`,
theme.ornaments.variantCount, theme.ornaments.variantCount,
old[0]?.decorVariant, old[old.length - 1]?.decorVariant,
) )
return [ const next = [
{ ...envelope, key, received: Date.now(), decorVariant },
...old.filter(item => item.key !== key), ...old.filter(item => item.key !== key),
].slice(0, current.maxVisible) { ...envelope, key, received: Date.now(), decorVariant },
]
return next.slice(Math.max(0, next.length - current.maxVisible))
}) })
} }
}, [preview, stream, stream?.messages]) }, [preview, stream, stream?.messages])
useEffect(() => { useEffect(() => {
setItems(current => current.slice(0, settings.maxVisible)) setItems(current => current.slice(Math.max(0, current.length - settings.maxVisible)))
}, [settings.maxVisible]) }, [settings.maxVisible])
return { settings, items, setItems } return { settings, items, setItems }
@@ -324,6 +326,7 @@ function Card({
<em>¥ {gift.priceCny.toFixed(2)}</em> <em>¥ {gift.priceCny.toFixed(2)}</em>
)} )}
</div> </div>
{isDanmaku && <i className="danmaku-lotus" aria-hidden="true" />}
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>} {tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
</article> </article>
) )
@@ -353,17 +356,6 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
useEffect(() => { useEffect(() => {
if (preview) { if (preview) {
setItems([ setItems([
{
id: 'text-preview',
key: 'text-preview',
received: Date.now(),
decorVariant: 0,
type: 'live.danmaku',
payload: {
viewer: { name: translate('overlay.preview_viewer') },
text: translate('overlay.preview_message'),
},
},
{ {
id: 'gift-preview', id: 'gift-preview',
key: 'gift-preview', key: 'gift-preview',
@@ -382,12 +374,23 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
}, },
}, },
}, },
{
id: 'text-preview',
key: 'text-preview',
received: Date.now(),
decorVariant: 0,
type: 'live.danmaku',
payload: {
viewer: { name: translate('overlay.preview_viewer') },
text: translate('overlay.preview_message'),
},
},
]) ])
} }
}, [items.length, language, preview, setItems]) }, [items.length, language, preview, setItems])
useEffect(() => { useEffect(() => {
const newest = items[0] const newest = items[items.length - 1]
if (!newest) { if (!newest) {
setExpandedKey(undefined) setExpandedKey(undefined)
return return
@@ -413,6 +416,9 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
...typographyVariables(settings.fontFamily, settings.fontBrightness), ...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-viewer-color' as string]: settings.viewerColor || undefined, ['--component-viewer-color' as string]: settings.viewerColor || undefined,
['--component-danmaku-color' as string]: settings.danmakuColor || undefined, ['--component-danmaku-color' as string]: settings.danmakuColor || undefined,
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--component-decoration-rail' as string]: `${settings.decorationLineWeight / 50}px`,
['--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`,
@@ -422,6 +428,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
} as CSSProperties } as CSSProperties
} }
> >
<ThemeEdges />
<section className="wall"> <section className="wall">
<div className="cards"> <div className="cards">
{items.map(item => ( {items.map(item => (
+9 -9
View File
@@ -26,7 +26,7 @@ body,
position: relative; position: relative;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border: 1px solid var(--theme-card-border); border: var(--component-decoration-line, 1px) solid var(--theme-card-border);
border-radius: clamp(8px, 1.5vmin, 15px); border-radius: clamp(8px, 1.5vmin, 15px);
background: var(--theme-card-background); background: var(--theme-card-background);
box-shadow: box-shadow:
@@ -60,7 +60,7 @@ body,
place-items: center; place-items: center;
width: clamp(30px, 3em, 46px); width: clamp(30px, 3em, 46px);
aspect-ratio: 1; aspect-ratio: 1;
border: 1px solid rgba(133, 255, 232, 0.5); border: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.5);
border-radius: 50%; border-radius: 50%;
color: var(--theme-accent); color: var(--theme-accent);
background: rgba(6, 48, 57, 0.72); background: rgba(6, 48, 57, 0.72);
@@ -127,7 +127,7 @@ body,
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.38em 0.72em; padding: 0.38em 0.72em;
border-bottom: 1px solid rgba(133, 255, 232, 0.16); border-bottom: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.16);
color: var(--theme-compact-user); color: var(--theme-compact-user);
letter-spacing: 0.12em; letter-spacing: 0.12em;
} }
@@ -161,7 +161,7 @@ body,
align-items: center; align-items: center;
gap: 0.55em; gap: 0.55em;
padding: 0.4em 0.62em; padding: 0.4em 0.62em;
border: 1px solid rgba(128, 238, 218, 0.13); border: var(--component-decoration-line, 1px) solid rgba(128, 238, 218, 0.13);
border-radius: 0.62em; border-radius: 0.62em;
background: linear-gradient(90deg, rgba(8, 47, 59, 0.74), rgba(8, 71, 72, 0.46)); background: linear-gradient(90deg, rgba(8, 47, 59, 0.74), rgba(8, 71, 72, 0.46));
overflow: hidden; overflow: hidden;
@@ -419,14 +419,14 @@ body,
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(10px, 1.5vmin, 18px); gap: clamp(10px, 1.5vmin, 18px);
padding: clamp(6px, 0.8vmin, 10px) 0 clamp(12px, 1.6vmin, 20px); padding: clamp(6px, 0.8vmin, 10px) 0 clamp(12px, 1.6vmin, 20px);
border-bottom: 1px solid rgba(198, 174, 122, 0.26); border-bottom: var(--component-decoration-line, 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 {
position: relative; position: relative;
width: clamp(46px, 3.7em, 68px); width: clamp(46px, 3.7em, 68px);
border: 1px solid rgba(198, 174, 122, 0.62); border: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.62);
border-radius: 50%; border-radius: 50%;
color: #d2b468; color: #d2b468;
background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%); background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%);
@@ -443,8 +443,8 @@ body,
right: -0.18em; right: -0.18em;
width: 0.72em; width: 0.72em;
height: 0.42em; height: 0.42em;
border: 1px solid rgba(116, 166, 157, 0.52); border: var(--component-decoration-line, 1px) solid rgba(116, 166, 157, 0.52);
border-width: 1px 0 0 1px; border-width: var(--component-decoration-line, 1px) 0 0 var(--component-decoration-line, 1px);
border-radius: 100% 0; border-radius: 100% 0;
transform: rotate(-24deg); transform: rotate(-24deg);
} }
@@ -510,7 +510,7 @@ body,
} }
.theme-moonlit-water .song-waveform i { .theme-moonlit-water .song-waveform i {
width: 2px; width: var(--component-decoration-line, 2px);
height: 25%; height: 25%;
background: #6a9f99; background: #6a9f99;
animation: moonlit-song-wave 1.8s ease-in-out infinite alternate; animation: moonlit-song-wave 1.8s ease-in-out infinite alternate;
+4
View File
@@ -6,6 +6,7 @@ import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream' import type { ComponentStream } from './stream'
import { getOverlayTheme, themeCssVariables } from './themes' import { getOverlayTheme, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes' import type { OverlayThemeDefinition } from './themes'
import { ThemeEdges } from './ThemeEdges'
import { defaultSongRequestSettings } from './types' import { defaultSongRequestSettings } from './types'
import type { SongRequestItem, SongRequestSettings } from './types' import type { SongRequestItem, SongRequestSettings } from './types'
import { typographyVariables } from './typography' import { typographyVariables } from './typography'
@@ -324,6 +325,8 @@ export function SongRequestOverlay({
...typographyVariables(settings.fontFamily, settings.fontBrightness), ...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-song-requester-color' as string]: settings.requesterColor || undefined, ['--component-song-requester-color' as string]: settings.requesterColor || undefined,
['--component-song-title-color' as string]: settings.songTitleColor || undefined, ['--component-song-title-color' as string]: settings.songTitleColor || undefined,
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}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-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-font-size' as string]: `${Math.min(26, Math.max(14, Math.min(bounds.width * 0.045, bounds.height * 0.032))) * (settings.fontScale / 100)}px`,
@@ -331,6 +334,7 @@ export function SongRequestOverlay({
} as CSSProperties } as CSSProperties
} }
> >
<ThemeEdges />
<section <section
className="song-current" className="song-current"
aria-label={translate('song.overlay.current_aria')} aria-label={translate('song.overlay.current_aria')}
+2 -1
View File
@@ -39,7 +39,8 @@ body {
position: relative; position: relative;
min-height: 58px; min-height: 58px;
padding: 11px 14px; padding: 11px 14px;
border: 1px solid var(--theme-card-border, rgba(101, 226, 211, 0.28)); border: var(--component-decoration-line, 1px) solid
var(--theme-card-border, rgba(101, 226, 211, 0.28));
border-radius: 14px; border-radius: 14px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
+75 -53
View File
@@ -1,70 +1,101 @@
/* Shared upper/lower ornament for the three text-led Moonlit Water widgets. /* Shared upper/lower ornament for the three text-led Moonlit Water widgets.
The SVG remains monochrome and transparent; this gradient owns its tint so It uses inline SVG so the control setting changes actual stroke width rather
future themes can reuse the mask with a different palette. */ than vertically stretching a thin bitmap/mask. */
:is( :is(
.overlay.theme-moonlit-water, .overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water, .song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water .gift-menu-overlay.gift-menu-theme-moonlit-water
) { ) {
--moonlit-edge-height: clamp(12px, 2.2vmin, 24px); --moonlit-edge-height: clamp(22px, 3.6vmin, 44px);
--moonlit-edge-offset: clamp(4px, 0.65vmin, 8px); --moonlit-edge-offset: clamp(4px, 0.65vmin, 8px);
--moonlit-edge-space: clamp(10px, 1.8vmin, 20px); --moonlit-edge-space: clamp(16px, 2.5vmin, 30px);
position: relative; position: relative;
isolation: isolate; isolation: isolate;
} }
.theme-edge-set {
display: none;
}
:is( :is(
.overlay.theme-moonlit-water, .overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water, .song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water .gift-menu-overlay.gift-menu-theme-moonlit-water
)::before, )
:is( > .theme-edge-set {
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
content: '';
position: absolute; position: absolute;
z-index: 5; z-index: 5;
left: clamp(8px, 1.4vmin, 18px); inset: 0;
right: clamp(8px, 1.4vmin, 18px); display: block;
height: var(--moonlit-edge-height); overflow: hidden;
pointer-events: none; pointer-events: none;
background: linear-gradient( user-select: none;
90deg, }
transparent 0%,
rgba(138, 174, 158, 0.7) 13%, .theme-edge-art {
rgba(211, 188, 127, 0.82) 39%, position: absolute;
#ead79f 50%, left: clamp(8px, 1.4vmin, 18px);
rgba(211, 188, 127, 0.82) 61%, width: calc(100% - clamp(16px, 2.8vmin, 36px));
rgba(138, 174, 158, 0.7) 87%, height: var(--moonlit-edge-height);
transparent 100% overflow: visible;
); filter: drop-shadow(0 0 3px rgba(205, 172, 88, 0.28));
-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; animation: moonlit-edge-breathe 7s ease-in-out infinite;
} }
:is( .theme-edge-top {
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::before {
top: var(--moonlit-edge-offset); top: var(--moonlit-edge-offset);
} }
:is( .theme-edge-bottom {
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)::after {
bottom: var(--moonlit-edge-offset); bottom: var(--moonlit-edge-offset);
transform: rotate(180deg); transform: rotate(180deg);
animation-delay: -3.5s; animation-delay: -3.5s;
} }
.theme-edge-rule,
.theme-edge-water {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: var(--component-decoration-edge-stroke, 3.2px);
}
.theme-edge-rule path,
.theme-edge-water path {
vector-effect: non-scaling-stroke;
}
.theme-edge-rule {
stroke: #d2b35f;
opacity: 0.94;
}
.theme-edge-water {
stroke: #5f978b;
opacity: 0.9;
}
.theme-edge-cloud {
fill: #568b80;
opacity: 0.76;
}
.theme-edge-leaves {
fill: #86aa92;
opacity: 0.88;
}
.theme-edge-lotus {
fill: #d4b45d;
opacity: 0.96;
filter: drop-shadow(0 0 2px rgba(221, 190, 104, 0.36));
}
.theme-edge-dots {
fill: #e4c978;
opacity: 0.94;
}
.overlay.theme-moonlit-water { .overlay.theme-moonlit-water {
padding-block: calc(clamp(14px, 2.3vw, 34px) + var(--moonlit-edge-space)); padding-block: calc(clamp(14px, 2.3vw, 34px) + var(--moonlit-edge-space));
} }
@@ -80,24 +111,15 @@
@keyframes moonlit-edge-breathe { @keyframes moonlit-edge-breathe {
0%, 0%,
100% { 100% {
opacity: 0.56; opacity: 0.78;
} }
50% { 50% {
opacity: 0.78; opacity: 1;
} }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
:is( .theme-edge-art {
.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; animation: none;
} }
} }
+1
View File
@@ -37,6 +37,7 @@ export function themeCssVariables(
'--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-pattern-edge': `url("${theme.ornaments.patterns.edge}")`,
'--theme-pattern-lotus': `url("${theme.ornaments.patterns.lotus}")`,
'--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,
+2 -1
View File
@@ -4,6 +4,7 @@ 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' const edge = '/assets/moonlit-edge.svg'
const lotus = '/assets/lotus-mark.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 = {
@@ -38,7 +39,7 @@ export const jadeScrollTheme: OverlayThemeDefinition = {
'star', 'star',
'floret', 'floret',
], ],
patterns: { divider, cluster, vine, edge }, patterns: { divider, cluster, vine, edge, lotus },
}, },
motion: { motion: {
arrive: 'arrive', arrive: 'arrive',
+2 -1
View File
@@ -4,6 +4,7 @@ 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' const edge = '/assets/moonlit-edge.svg'
const lotus = '/assets/lotus-mark.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.
@@ -31,7 +32,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, edge }, patterns: { divider, cluster, vine, edge, lotus },
}, },
motion: { motion: {
arrive: 'moonlit-line-arrive', arrive: 'moonlit-line-arrive',
+1
View File
@@ -35,6 +35,7 @@ export type OverlayThemeDefinition = {
cluster: string cluster: string
vine: string vine: string
edge: string edge: string
lotus: string
} }
} }
motion: { motion: {
+6
View File
@@ -15,6 +15,7 @@ export type OverlaySettings = {
viewerColor: string | null viewerColor: string | null
danmakuColor: string | null danmakuColor: string | null
fontScale: number fontScale: number
decorationLineWeight: number
showDanmaku: boolean showDanmaku: boolean
showEnter: boolean showEnter: boolean
showGift: boolean showGift: boolean
@@ -40,6 +41,7 @@ export const defaultOverlaySettings: OverlaySettings = {
viewerColor: null, viewerColor: null,
danmakuColor: null, danmakuColor: null,
fontScale: 140, fontScale: 140,
decorationLineWeight: 160,
showDanmaku: true, showDanmaku: true,
showEnter: true, showEnter: true,
showGift: true, showGift: true,
@@ -66,6 +68,7 @@ export type SongRequestSettings = {
requesterColor: string | null requesterColor: string | null
songTitleColor: string | null songTitleColor: string | null
fontScale: number fontScale: number
decorationLineWeight: number
scrollSpeedPixelsPerSecond: number scrollSpeedPixelsPerSecond: number
edgePauseSeconds: number edgePauseSeconds: number
/** Zero keeps the corresponding business limit disabled. */ /** Zero keeps the corresponding business limit disabled. */
@@ -81,6 +84,7 @@ export const defaultSongRequestSettings: SongRequestSettings = {
requesterColor: null, requesterColor: null,
songTitleColor: null, songTitleColor: null,
fontScale: 100, fontScale: 100,
decorationLineWeight: 160,
scrollSpeedPixelsPerSecond: 28, scrollSpeedPixelsPerSecond: 28,
edgePauseSeconds: 2, edgePauseSeconds: 2,
maxQueueSize: 0, maxQueueSize: 0,
@@ -169,6 +173,7 @@ export type GiftMenuSettings = {
pageIntervalMs: number pageIntervalMs: number
highlightDurationMs: number highlightDurationMs: number
fontScale: number fontScale: number
decorationLineWeight: number
motionIntensity: number motionIntensity: number
lowPerformanceMode: boolean lowPerformanceMode: boolean
} }
@@ -184,6 +189,7 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
pageIntervalMs: 6_000, pageIntervalMs: 6_000,
highlightDurationMs: 3_800, highlightDurationMs: 3_800,
fontScale: 100, fontScale: 100,
decorationLineWeight: 160,
motionIntensity: 78, motionIntensity: 78,
lowPerformanceMode: false, lowPerformanceMode: false,
} }
@@ -0,0 +1,31 @@
-- Short-lived, tenant-owned TOTP replacement enrollments.
--
-- The current TOTP secret remains authoritative until the replacement secret
-- has produced a valid code. Raw enrollment tokens are never persisted, and
-- pending secrets use the same authenticated encryption boundary as account
-- TOTP secrets.
CREATE TABLE IF NOT EXISTS pending_totp_resets (
id UUID PRIMARY KEY,
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
enrollment_token_digest BYTEA NOT NULL UNIQUE
CHECK (octet_length(enrollment_token_digest) = 32),
totp_secret_ciphertext BYTEA NOT NULL
CHECK (octet_length(totp_secret_ciphertext) >= 16),
totp_secret_nonce BYTEA NOT NULL
CHECK (octet_length(totp_secret_nonce) = 24),
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (user_id, id)
);
CREATE INDEX IF NOT EXISTS pending_totp_resets_expiry_idx
ON pending_totp_resets(expires_at);
ALTER TABLE pending_totp_resets ENABLE ROW LEVEL SECURITY;
ALTER TABLE pending_totp_resets FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS pending_totp_resets_owner ON pending_totp_resets;
CREATE POLICY pending_totp_resets_owner ON pending_totp_resets
USING (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
+1
View File
@@ -301,6 +301,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
), ),
(9_i32, include_str!("../migrations/009_gift_effect.sql")), (9_i32, include_str!("../migrations/009_gift_effect.sql")),
(10_i32, include_str!("../migrations/010_gift_menu.sql")), (10_i32, include_str!("../migrations/010_gift_menu.sql")),
(11_i32, include_str!("../migrations/011_totp_reset.sql")),
] { ] {
let applied = transaction let applied = transaction
.query_one( .query_one(
+275
View File
@@ -891,6 +891,251 @@ impl AuthService {
Ok(codes) Ok(codes)
} }
/// Begin replacement of the current account's TOTP secret after explicit
/// step-up authentication. The existing secret remains valid until
/// `totp_reset_confirm` commits the replacement.
pub async fn totp_reset_start(
&self,
user_id: Uuid,
current_code: &str,
) -> Result<TotpResetStart, AuthError> {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
let row = transaction
.query_opt(
"SELECT username,room_id,totp_secret_ciphertext,totp_secret_nonce,last_totp_step \
FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&user_id],
)
.await?
.ok_or(AuthError::InvalidCredentials)?;
let username: String = row.get(0);
let room_id: String = row.get(1);
let proof_kind = if is_totp_code(current_code) {
let secret = self.cipher.decrypt(
&EncryptedSecret {
ciphertext: row.get(2),
nonce: row.get(3),
},
format!("user-totp:{user_id}").as_bytes(),
)?;
let accepted_step = accepted_totp_step(
&secret,
&self.issuer,
&username,
current_code,
Utc::now().timestamp(),
row.get(4),
)?
.ok_or(AuthError::InvalidCredentials)?;
let changed = transaction
.execute(
"UPDATE users SET last_totp_step=$1,updated_at=now() \
WHERE id=$2 AND (last_totp_step IS NULL OR last_totp_step<$1)",
&[&accepted_step, &user_id],
)
.await?;
if changed != 1 {
return Err(AuthError::TotpReplay);
}
"totp"
} else {
let recovery_digest = token_digest(current_code.trim());
let recovery_id = transaction
.query_opt(
"SELECT id FROM recovery_codes \
WHERE user_id=$1 AND code_digest=$2 AND consumed_at IS NULL FOR UPDATE",
&[&user_id, &recovery_digest],
)
.await?
.ok_or(AuthError::InvalidCredentials)?
.get::<_, Uuid>(0);
transaction
.execute(
"UPDATE recovery_codes SET consumed_at=now() WHERE id=$1",
&[&recovery_id],
)
.await?;
"recovery"
};
let reset_id = Uuid::new_v4();
let enrollment_token = random_token("totp-reset", 32);
let enrollment_digest = token_digest(&enrollment_token);
let secret = Secret::generate_secret();
let secret_bytes = secret
.to_bytes()
.map_err(|error| AuthError::Totp(error.to_string()))?;
let encoded = secret.to_encoded();
let encoded_secret = match &encoded {
Secret::Encoded(value) => value.clone(),
Secret::Raw(_) => unreachable!("to_encoded always returns Secret::Encoded"),
};
let totp = build_totp(&secret_bytes, &self.issuer, &username)?;
let otpauth_uri = totp.get_url();
let qr = totp
.get_qr_base64()
.map_err(|error| AuthError::Totp(error.to_string()))?;
let qr_data_url = if qr.starts_with("data:") {
qr
} else {
format!("data:image/png;base64,{qr}")
};
let encrypted = self.cipher.encrypt(
&secret_bytes,
format!("pending-totp-reset:{reset_id}").as_bytes(),
)?;
let expires_at = future_time(self.enrollment_ttl)?;
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"DELETE FROM pending_totp_resets WHERE user_id=$1",
&[&user_id],
)
.await?;
transaction
.execute(
"INSERT INTO pending_totp_resets \
(id,user_id,enrollment_token_digest,totp_secret_ciphertext,totp_secret_nonce,expires_at) \
VALUES($1,$2,$3,$4,$5,$6)",
&[
&reset_id,
&user_id,
&enrollment_digest,
&encrypted.ciphertext,
&encrypted.nonce,
&expires_at,
],
)
.await?;
insert_audit(
&transaction,
Some(user_id),
"auth.totp_reset.started",
"user",
Some(user_id.to_string()),
json!({"proof":proof_kind}),
)
.await?;
transaction.commit().await?;
Ok(TotpResetStart {
enrollment_token,
username,
room_id,
secret: encoded_secret,
otpauth_uri,
qr_data_url,
expires_at,
})
}
/// Confirm a pending replacement, rotate recovery codes, and revoke every
/// other browser session atomically. The session performing the reset is
/// retained so it can display the one-time recovery codes.
pub async fn totp_reset_confirm(
&self,
user_id: Uuid,
current_session_id: Uuid,
enrollment_token: &str,
new_totp_code: &str,
) -> Result<Vec<String>, AuthError> {
let digest = token_digest(enrollment_token.trim());
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, user_id).await?;
let row = transaction
.query_opt(
"SELECT r.id,r.totp_secret_ciphertext,r.totp_secret_nonce,r.expires_at,\
r.failed_attempts,u.username \
FROM pending_totp_resets r JOIN users u ON u.id=r.user_id \
WHERE r.user_id=$1 AND r.enrollment_token_digest=$2 FOR UPDATE OF r,u",
&[&user_id, &digest],
)
.await?
.ok_or(AuthError::TotpResetUnavailable)?;
let reset_id: Uuid = row.get(0);
let expires_at: DateTime<Utc> = row.get(3);
let failed_attempts: i32 = row.get(4);
if expires_at <= Utc::now() {
return Err(AuthError::TotpResetUnavailable);
}
let username: String = row.get(5);
let secret = self.cipher.decrypt(
&EncryptedSecret {
ciphertext: row.get(1),
nonce: row.get(2),
},
format!("pending-totp-reset:{reset_id}").as_bytes(),
)?;
let accepted_step = accepted_totp_step(
&secret,
&self.issuer,
&username,
new_totp_code,
Utc::now().timestamp(),
None,
)?;
let Some(accepted_step) = accepted_step else {
if failed_attempts + 1 >= MAX_PENDING_TOTP_FAILURES {
transaction
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
.await?;
} else {
transaction
.execute(
"UPDATE pending_totp_resets SET failed_attempts=failed_attempts+1 WHERE id=$1",
&[&reset_id],
)
.await?;
}
transaction.commit().await?;
return Err(AuthError::InvalidTotp);
};
let encrypted = self
.cipher
.encrypt(&secret, format!("user-totp:{user_id}").as_bytes())?;
let updated = transaction
.execute(
"UPDATE users SET totp_secret_ciphertext=$1,totp_secret_nonce=$2,\
last_totp_step=$3,totp_enrolled_at=now(),auth_version=auth_version+1,updated_at=now() \
WHERE id=$4 AND status='active'",
&[
&encrypted.ciphertext,
&encrypted.nonce,
&accepted_step,
&user_id,
],
)
.await?;
if updated != 1 {
return Err(AuthError::TotpResetUnavailable);
}
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
let revoked_sessions = transaction
.execute(
"UPDATE user_sessions SET revoked_at=now() \
WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL",
&[&user_id, &current_session_id],
)
.await?;
transaction
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
.await?;
insert_audit(
&transaction,
Some(user_id),
"auth.totp_reset.completed",
"user",
Some(user_id.to_string()),
json!({"revokedSessions":revoked_sessions}),
)
.await?;
transaction.commit().await?;
Ok(recovery_codes)
}
pub async fn authenticate_session( pub async fn authenticate_session(
&self, &self,
raw_session_token: &str, raw_session_token: &str,
@@ -1510,6 +1755,11 @@ fn accepted_totp_step(
Ok(None) Ok(None)
} }
fn is_totp_code(code: &str) -> bool {
let code = code.trim();
code.len() == TOTP_DIGITS && code.bytes().all(|byte| byte.is_ascii_digit())
}
async fn require_system_admin( async fn require_system_admin(
transaction: &Transaction<'_>, transaction: &Transaction<'_>,
user_id: Uuid, user_id: Uuid,
@@ -1790,6 +2040,18 @@ pub struct RegistrationStart {
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
} }
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TotpResetStart {
pub enrollment_token: String,
pub username: String,
pub room_id: String,
pub secret: String,
pub otpauth_uri: String,
pub qr_data_url: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RegistrationComplete { pub struct RegistrationComplete {
@@ -1847,6 +2109,7 @@ pub enum AuthError {
Forbidden, Forbidden,
InvitationUnavailable, InvitationUnavailable,
EnrollmentUnavailable, EnrollmentUnavailable,
TotpResetUnavailable,
AccountUnavailable, AccountUnavailable,
RoomUnavailable, RoomUnavailable,
InvalidTotp, InvalidTotp,
@@ -1879,6 +2142,9 @@ impl fmt::Display for AuthError {
formatter, formatter,
"registration enrollment is invalid or unavailable" "registration enrollment is invalid or unavailable"
), ),
Self::TotpResetUnavailable => {
write!(formatter, "TOTP reset is invalid or unavailable")
}
Self::AccountUnavailable => write!(formatter, "username or room is unavailable"), Self::AccountUnavailable => write!(formatter, "username or room is unavailable"),
Self::RoomUnavailable => write!(formatter, "room is already assigned"), Self::RoomUnavailable => write!(formatter, "room is already assigned"),
Self::InvalidTotp => write!(formatter, "invalid TOTP code"), Self::InvalidTotp => write!(formatter, "invalid TOTP code"),
@@ -1968,6 +2234,15 @@ mod tests {
); );
} }
#[test]
fn step_up_proof_only_classifies_exact_six_digit_totp_codes() {
assert!(is_totp_code(" 012345 "));
assert!(!is_totp_code("12345"));
assert!(!is_totp_code("1234567"));
assert!(!is_totp_code("12a456"));
assert!(!is_totp_code("recovery-example"));
}
#[test] #[test]
fn username_and_room_validation_are_canonical() { fn username_and_room_validation_are_canonical() {
assert_eq!( assert_eq!(
+4
View File
@@ -133,6 +133,7 @@ struct OverlayFileConfig {
viewer_color: Option<String>, viewer_color: Option<String>,
danmaku_color: Option<String>, danmaku_color: Option<String>,
font_scale: Option<u16>, font_scale: Option<u16>,
decoration_line_weight: Option<u16>,
max_visible: Option<u8>, max_visible: Option<u8>,
collapse_after_seconds: Option<u16>, collapse_after_seconds: Option<u16>,
unfold_duration_ms: Option<u16>, unfold_duration_ms: Option<u16>,
@@ -333,6 +334,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
viewer_color: file.viewer_color.or(default.viewer_color), viewer_color: file.viewer_color.or(default.viewer_color),
danmaku_color: file.danmaku_color.or(default.danmaku_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),
decoration_line_weight: file
.decoration_line_weight
.unwrap_or(default.decoration_line_weight),
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),
show_gift: file.events.gift.unwrap_or(default.show_gift), show_gift: file.events.gift.unwrap_or(default.show_gift),
+15
View File
@@ -93,6 +93,8 @@ pub struct GiftMenuSettings {
pub page_interval_ms: u16, pub page_interval_ms: u16,
pub highlight_duration_ms: u16, pub highlight_duration_ms: u16,
pub font_scale: u16, pub font_scale: u16,
#[serde(default = "default_decoration_line_weight")]
pub decoration_line_weight: u16,
pub motion_intensity: u8, pub motion_intensity: u8,
pub low_performance_mode: bool, pub low_performance_mode: bool,
} }
@@ -110,6 +112,7 @@ impl Default for GiftMenuSettings {
page_interval_ms: default_page_interval_ms(), page_interval_ms: default_page_interval_ms(),
highlight_duration_ms: 3_800, highlight_duration_ms: 3_800,
font_scale: 100, font_scale: 100,
decoration_line_weight: default_decoration_line_weight(),
motion_intensity: 78, motion_intensity: 78,
low_performance_mode: false, low_performance_mode: false,
} }
@@ -140,11 +143,16 @@ impl GiftMenuSettings {
self.page_interval_ms = self.page_interval_ms.clamp(1_500, 30_000); 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.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
self.motion_intensity = self.motion_intensity.min(100); self.motion_intensity = self.motion_intensity.min(100);
Ok(self) Ok(self)
} }
} }
const fn default_decoration_line_weight() -> u16 {
160
}
fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> { fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" "); let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let count = normalized.chars().count(); let count = normalized.chars().count();
@@ -385,6 +393,7 @@ mod tests {
page_interval_ms: u16::MAX, page_interval_ms: u16::MAX,
font_scale: 1, font_scale: 1,
font_brightness: 1, font_brightness: 1,
decoration_line_weight: 1,
..GiftMenuSettings::default() ..GiftMenuSettings::default()
}; };
let sanitized = definition let sanitized = definition
@@ -396,6 +405,7 @@ mod tests {
assert_eq!(sanitized["pageIntervalMs"], 30_000); 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);
assert_eq!(sanitized["decorationLineWeight"], 50);
} }
#[test] #[test]
@@ -403,10 +413,15 @@ mod tests {
let definition = GiftMenuDefinition; let definition = GiftMenuDefinition;
let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap(); let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap();
settings.as_object_mut().unwrap().remove("pageIntervalMs"); settings.as_object_mut().unwrap().remove("pageIntervalMs");
settings
.as_object_mut()
.unwrap()
.remove("decorationLineWeight");
let sanitized = definition.validate_settings(settings).unwrap(); let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms()); assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
assert_eq!(sanitized["decorationLineWeight"], 160);
} }
#[test] #[test]
+57
View File
@@ -54,6 +54,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/v1/auth/register/confirm", post(enrollment_confirm)) .route("/api/v1/auth/register/confirm", post(enrollment_confirm))
.route("/api/v1/auth/login", post(login)) .route("/api/v1/auth/login", post(login))
.route("/api/v1/auth/logout", post(logout)) .route("/api/v1/auth/logout", post(logout))
.route("/api/v1/auth/totp/reset/start", post(totp_reset_start))
.route("/api/v1/auth/totp/reset/confirm", post(totp_reset_confirm))
.route( .route(
"/api/v1/invitations", "/api/v1/invitations",
get(list_invitations).post(create_invitation), get(list_invitations).post(create_invitation),
@@ -433,6 +435,56 @@ async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Res
Ok(response) Ok(response)
} }
#[derive(Deserialize)]
struct TotpResetStartRequest {
code: String,
}
async fn totp_reset_start(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TotpResetStartRequest>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
consume_enrollment_budget(&state, &headers).await?;
let enrollment = state
.auth
.totp_reset_start(session.user.id, &body.code)
.await?;
Ok(Json(serde_json::to_value(enrollment).map_err(internal)?))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TotpResetConfirmRequest {
enrollment_token: String,
code: String,
}
async fn totp_reset_confirm(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TotpResetConfirmRequest>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
consume_enrollment_budget(&state, &headers).await?;
let recovery_codes = state
.auth
.totp_reset_confirm(
session.user.id,
session.session_id,
&body.enrollment_token,
&body.code,
)
.await?;
Ok(Json(json!({
"ok": true,
"recoveryCodes": recovery_codes,
})))
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CreateInvitationRequest { struct CreateInvitationRequest {
@@ -1564,6 +1616,11 @@ impl From<AuthError> for ApiError {
"enrollment_unavailable", "enrollment_unavailable",
error.to_string(), error.to_string(),
), ),
AuthError::TotpResetUnavailable => Self::new(
StatusCode::BAD_REQUEST,
"totp_reset_unavailable",
error.to_string(),
),
AuthError::AccountUnavailable => Self::new( AuthError::AccountUnavailable => Self::new(
StatusCode::CONFLICT, StatusCode::CONFLICT,
"account_unavailable", "account_unavailable",
+13
View File
@@ -46,6 +46,9 @@ pub struct OverlaySettings {
pub danmaku_color: Option<String>, pub danmaku_color: Option<String>,
#[serde(default = "default_font_scale")] #[serde(default = "default_font_scale")]
pub font_scale: u16, pub font_scale: u16,
/// Relative weight of theme borders, rules, and ornamental edge artwork.
#[serde(default = "default_decoration_line_weight")]
pub decoration_line_weight: u16,
pub show_danmaku: bool, pub show_danmaku: bool,
pub show_enter: bool, pub show_enter: bool,
pub show_gift: bool, pub show_gift: bool,
@@ -77,6 +80,7 @@ impl Default for OverlaySettings {
viewer_color: None, viewer_color: None,
danmaku_color: None, danmaku_color: None,
font_scale: default_font_scale(), font_scale: default_font_scale(),
decoration_line_weight: default_decoration_line_weight(),
show_danmaku: true, show_danmaku: true,
show_enter: true, show_enter: true,
show_gift: true, show_gift: true,
@@ -104,6 +108,7 @@ impl OverlaySettings {
self.viewer_color = sanitize_optional_hex_color(self.viewer_color); self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_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.decoration_line_weight = self.decoration_line_weight.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);
self.motion_intensity = self.motion_intensity.min(100); self.motion_intensity = self.motion_intensity.min(100);
@@ -128,6 +133,10 @@ fn default_font_scale() -> u16 {
140 140
} }
fn default_decoration_line_weight() -> u16 {
160
}
fn default_unfold_duration_ms() -> u16 { fn default_unfold_duration_ms() -> u16 {
1_000 1_000
} }
@@ -577,6 +586,7 @@ mod tests {
font_brightness: 1, font_brightness: 1,
viewer_color: Some(" #a1b2c3 ".into()), viewer_color: Some(" #a1b2c3 ".into()),
danmaku_color: Some("not-css".into()), danmaku_color: Some("not-css".into()),
decoration_line_weight: 999,
max_visible: 99, max_visible: 99,
collapse_after_seconds: 1, collapse_after_seconds: 1,
unfold_duration_ms: 9_000, unfold_duration_ms: 9_000,
@@ -592,6 +602,7 @@ mod tests {
assert_eq!(settings.font_brightness, 70); assert_eq!(settings.font_brightness, 70);
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3")); assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
assert_eq!(settings.danmaku_color, None); assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.decoration_line_weight, 300);
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);
@@ -618,6 +629,7 @@ mod tests {
object.remove("fontBrightness"); object.remove("fontBrightness");
object.remove("viewerColor"); object.remove("viewerColor");
object.remove("danmakuColor"); object.remove("danmakuColor");
object.remove("decorationLineWeight");
object.remove("unfoldDurationMs"); object.remove("unfoldDurationMs");
object.remove("particleCount"); object.remove("particleCount");
object.remove("particleSpeed"); object.remove("particleSpeed");
@@ -629,6 +641,7 @@ mod tests {
assert_eq!(settings.viewer_color, None); assert_eq!(settings.viewer_color, None);
assert_eq!(settings.danmaku_color, None); assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.font_scale, 140); assert_eq!(settings.font_scale, 140);
assert_eq!(settings.decoration_line_weight, 160);
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);
assert_eq!(settings.particle_speed, 100); assert_eq!(settings.particle_speed, 100);
+10
View File
@@ -46,6 +46,8 @@ pub struct SongRequestSettings {
pub song_title_color: Option<String>, 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_decoration_line_weight")]
pub decoration_line_weight: u16,
#[serde(default = "default_scroll_speed")] #[serde(default = "default_scroll_speed")]
pub scroll_speed_pixels_per_second: u16, pub scroll_speed_pixels_per_second: u16,
#[serde(default = "default_edge_pause")] #[serde(default = "default_edge_pause")]
@@ -70,6 +72,7 @@ impl Default for SongRequestSettings {
requester_color: None, requester_color: None,
song_title_color: None, song_title_color: None,
font_scale: default_font_scale(), font_scale: default_font_scale(),
decoration_line_weight: default_decoration_line_weight(),
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(),
max_queue_size: 0, max_queue_size: 0,
@@ -82,6 +85,7 @@ impl Default for SongRequestSettings {
impl SongRequestSettings { 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.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
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.requester_color = sanitize_optional_hex_color(self.requester_color);
self.song_title_color = sanitize_optional_hex_color(self.song_title_color); self.song_title_color = sanitize_optional_hex_color(self.song_title_color);
@@ -106,6 +110,10 @@ const fn default_font_scale() -> u16 {
100 100
} }
const fn default_decoration_line_weight() -> u16 {
160
}
const fn default_scroll_speed() -> u16 { const fn default_scroll_speed() -> u16 {
28 28
} }
@@ -1098,6 +1106,7 @@ mod tests {
font_brightness: u16::MAX, font_brightness: u16::MAX,
requester_color: Some(" #a1b2c3 ".into()), requester_color: Some(" #a1b2c3 ".into()),
song_title_color: Some("transparent".into()), song_title_color: Some("transparent".into()),
decoration_line_weight: 999,
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,
@@ -1110,6 +1119,7 @@ mod tests {
assert_eq!(bounded.font_brightness, 180); assert_eq!(bounded.font_brightness, 180);
assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3")); assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3"));
assert_eq!(bounded.song_title_color, None); assert_eq!(bounded.song_title_color, None);
assert_eq!(bounded.decoration_line_weight, 300);
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);
+2
View File
@@ -100,6 +100,8 @@ font_brightness = 130
# danmaku_color = "#C6AE7A" # danmaku_color = "#C6AE7A"
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。 # 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
font_scale = 140 font_scale = 140
# 主题装饰线、边框与上下古风边缘的相对粗细;允许范围 50-300。
decoration_line_weight = 160
max_visible = 5 max_visible = 5
collapse_after_seconds = 12 collapse_after_seconds = 12
# 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。 # 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。
+3 -2
View File
@@ -20,6 +20,7 @@
| `viewerColor` | `#RRGGBB` | 可选的用户昵称颜色覆盖 | | `viewerColor` | `#RRGGBB` | 可选的用户昵称颜色覆盖 |
| `danmakuColor` | `#RRGGBB` | 可选的弹幕正文颜色覆盖 | | `danmakuColor` | `#RRGGBB` | 可选的弹幕正文颜色覆盖 |
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 | | `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
| `decorationLineWeight` | 50–300% | 边框、分隔线与古风边缘粗细 |
| `maxVisible` | 1–12 | 同时保留的消息卡数量 | | `maxVisible` | 1–12 | 同时保留的消息卡数量 |
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 | | `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 | | `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
@@ -51,13 +52,13 @@ input 只改善交互,不能取代服务端校验。字体 ID 只映射到前
## 卡片生命周期 ## 卡片生命周期
1. 新事件插入队首,以卷轴动画横向展开。 1. 新事件追加在可视区域底部,以卷轴动画横向展开;旧事件被向上顶出并裁切。
2. 用户名与内容在展开态分行显示,长内容完整换行。 2. 用户名与内容在展开态分行显示,长内容完整换行。
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。 3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。 4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
5. 超过 `maxVisible` 的最旧卡才会离开队列。 5. 超过 `maxVisible` 的最旧卡才会离开队列。
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。 花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。每条普通弹幕右端还会显示主题着色的内置透明莲花图案;渲染时预留文字空间,不会遮挡昵称、正文或表情。
## 礼物展示 ## 礼物展示
+1 -1
View File
@@ -25,7 +25,7 @@ schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电
## OBS 行为 ## OBS 行为
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、高亮时长和动效强度均可调整。`jade-banquet` 每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、装饰线粗细、高亮时长和动效强度均可调整。`jade-banquet`
仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。 仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。 命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。
+1 -1
View File
@@ -13,7 +13,7 @@
## 设置 ## 设置
`themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`scrollSpeedPixelsPerSecond` `themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`decorationLineWeight`、`scrollSpeedPixelsPerSecond`
和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与 和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。 `requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
+2
View File
@@ -37,6 +37,8 @@ token。以下规则是实现约束,而不是可选部署建议。
- TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。 - TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。
- 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。 - 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。
- 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。 - 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。
- 已登录账户可用当前 TOTP 或未消费恢复码开始短期 TOTP replacement
enrollment。旧密钥在新动态码确认前保持有效;确认事务会更换密钥、轮换全部恢复码并撤销除当前会话外的其他网页登录会话。
## CookieCloud 与 SSRF ## CookieCloud 与 SSRF
+42
View File
@@ -56,6 +56,7 @@ name = "简体中文"
"api.error.enrollment_rate_limited" = "注册尝试过于频繁,请稍后重试。" "api.error.enrollment_rate_limited" = "注册尝试过于频繁,请稍后重试。"
"api.error.invitation_unavailable" = "邀请码无效、已过期或已使用。" "api.error.invitation_unavailable" = "邀请码无效、已过期或已使用。"
"api.error.enrollment_unavailable" = "注册流程无效或已过期。" "api.error.enrollment_unavailable" = "注册流程无效或已过期。"
"api.error.totp_reset_unavailable" = "TOTP 重置流程无效或已过期,请重新开始。"
"api.error.account_unavailable" = "账户不可用或用户名已被使用。" "api.error.account_unavailable" = "账户不可用或用户名已被使用。"
"api.error.song_request_not_found" = "点歌记录不存在。" "api.error.song_request_not_found" = "点歌记录不存在。"
"api.error.song_request_conflict" = "点歌队列已变化,请刷新后重试。" "api.error.song_request_conflict" = "点歌队列已变化,请刷新后重试。"
@@ -152,6 +153,8 @@ name = "简体中文"
"settings.use_theme_color" = "跟随主题" "settings.use_theme_color" = "跟随主题"
"settings.text_color_description" = "选择后覆盖主题颜色;恢复后会随主题自动变化。" "settings.text_color_description" = "选择后覆盖主题颜色;恢复后会随主题自动变化。"
"settings.font_size" = "字号" "settings.font_size" = "字号"
"settings.decoration_line_weight" = "装饰线粗细"
"settings.decoration_line_weight_description" = "统一调整主题边框、分隔线和上下古风装饰边缘的视觉粗细。"
"settings.max_visible" = "最大可见条数" "settings.max_visible" = "最大可见条数"
"settings.auto_collapse" = "自动收缩" "settings.auto_collapse" = "自动收缩"
"settings.unfold_duration" = "卷轴展开时长" "settings.unfold_duration" = "卷轴展开时长"
@@ -284,6 +287,24 @@ name = "简体中文"
"account.eyebrow" = "账户直播源" "account.eyebrow" = "账户直播源"
"account.description" = "配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。" "account.description" = "配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。"
"account.loading" = "正在读取账户直播源…" "account.loading" = "正在读取账户直播源…"
"security.title" = "账户安全"
"security.description" = "为当前登录账户更换 TOTP 验证器;旧密钥会保持有效,直到新动态码确认成功。"
"security.totp_reset_warning" = "需要使用当前动态验证码或一个未使用的恢复码再次验证身份。重置成功后,其他网页登录会话将被撤销。"
"security.current_totp_code" = "当前 6 位动态验证码"
"security.use_recovery_for_reset" = "验证器不可用?使用恢复码重置"
"security.start_totp_reset" = "开始更换验证器"
"security.preparing_totp" = "正在生成新 TOTP…"
"security.totp_reset_start_failed" = "无法开始 TOTP 重置"
"security.totp_reset_missing_id" = "服务端没有返回有效的 TOTP 重置标识"
"security.old_totp_still_active" = "扫描并确认新二维码前,原有 TOTP 仍然有效;此时取消不会锁定账户。"
"security.new_totp_code" = "新验证器中的 6 位动态验证码"
"security.confirm_totp_reset" = "确认并启用新 TOTP"
"security.cancel_totp_reset" = "取消本次重置"
"security.totp_reset_cancelled" = "已取消;原有 TOTP 未被修改。"
"security.totp_reset_confirm_failed" = "新动态验证码无效,或重置流程已经过期"
"security.recovery_replaced" = "TOTP 已更换。原恢复码全部失效;以下新恢复码仅显示一次,请立即离线保存。"
"security.totp_reset_complete" = "新 TOTP 和恢复码已启用。"
"security.totp_reset_blocker" = "完成或取消 TOTP 重置,并保存新的一次性恢复码"
"song.queue_changing" = "点歌队列正在频繁变化,请稍后重试" "song.queue_changing" = "点歌队列正在频繁变化,请稍后重试"
"song.queue_load_failed" = "点歌队列读取失败" "song.queue_load_failed" = "点歌队列读取失败"
"song.cancel_confirm" = "确定取消「{title}」吗?" "song.cancel_confirm" = "确定取消「{title}」吗?"
@@ -535,6 +556,7 @@ name = "English"
"api.error.enrollment_rate_limited" = "Too many registration attempts. Try again later." "api.error.enrollment_rate_limited" = "Too many registration attempts. Try again later."
"api.error.invitation_unavailable" = "The invitation is invalid, expired, or already used." "api.error.invitation_unavailable" = "The invitation is invalid, expired, or already used."
"api.error.enrollment_unavailable" = "The registration flow is invalid or expired." "api.error.enrollment_unavailable" = "The registration flow is invalid or expired."
"api.error.totp_reset_unavailable" = "The TOTP reset is invalid or expired. Start again."
"api.error.account_unavailable" = "The account is unavailable or the username is already used." "api.error.account_unavailable" = "The account is unavailable or the username is already used."
"api.error.song_request_not_found" = "The song request does not exist." "api.error.song_request_not_found" = "The song request does not exist."
"api.error.song_request_conflict" = "The song queue changed. Refresh and try again." "api.error.song_request_conflict" = "The song queue changed. Refresh and try again."
@@ -631,6 +653,8 @@ name = "English"
"settings.use_theme_color" = "Use theme color" "settings.use_theme_color" = "Use theme color"
"settings.text_color_description" = "Overrides the theme when selected; reset to follow future theme changes." "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.decoration_line_weight" = "Decoration line weight"
"settings.decoration_line_weight_description" = "Adjusts theme borders, dividers, and the upper and lower ornamental edges together."
"settings.max_visible" = "Maximum visible items" "settings.max_visible" = "Maximum visible items"
"settings.auto_collapse" = "Auto-collapse" "settings.auto_collapse" = "Auto-collapse"
"settings.unfold_duration" = "Scroll-open duration" "settings.unfold_duration" = "Scroll-open duration"
@@ -763,6 +787,24 @@ name = "English"
"account.eyebrow" = "ACCOUNT LIVE SOURCE" "account.eyebrow" = "ACCOUNT LIVE SOURCE"
"account.description" = "Configure the room and CookieCloud once for every current and future component in this account." "account.description" = "Configure the room and CookieCloud once for every current and future component in this account."
"account.loading" = "Loading account live source…" "account.loading" = "Loading account live source…"
"security.title" = "Account security"
"security.description" = "Replace the TOTP authenticator for the current account. The old secret remains active until the new code is confirmed."
"security.totp_reset_warning" = "Verify again with the current TOTP or an unused recovery code. A successful reset revokes every other web login session."
"security.current_totp_code" = "Current six-digit TOTP code"
"security.use_recovery_for_reset" = "Authenticator unavailable? Reset with a recovery code"
"security.start_totp_reset" = "Replace authenticator"
"security.preparing_totp" = "Generating new TOTP…"
"security.totp_reset_start_failed" = "Could not start the TOTP reset"
"security.totp_reset_missing_id" = "The server did not return a valid TOTP reset identifier"
"security.old_totp_still_active" = "Your old TOTP remains active until this QR code is confirmed. Cancelling now cannot lock the account."
"security.new_totp_code" = "Six-digit code from the new authenticator"
"security.confirm_totp_reset" = "Confirm and enable new TOTP"
"security.cancel_totp_reset" = "Cancel this reset"
"security.totp_reset_cancelled" = "Reset cancelled. The existing TOTP was not changed."
"security.totp_reset_confirm_failed" = "The new code is invalid or the reset has expired"
"security.recovery_replaced" = "TOTP was replaced and every old recovery code is invalid. Save these new one-time codes offline now; they are shown only once."
"security.totp_reset_complete" = "The new TOTP and recovery codes are active."
"security.totp_reset_blocker" = "finish or cancel TOTP reset and save the new one-time recovery codes"
"song.queue_changing" = "The song queue is changing rapidly. Try again shortly." "song.queue_changing" = "The song queue is changing rapidly. Try again shortly."
"song.queue_load_failed" = "Could not load the song queue" "song.queue_load_failed" = "Could not load the song queue"
"song.cancel_confirm" = "Cancel “{title}”?" "song.cancel_confirm" = "Cancel “{title}”?"