diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index 9b03c7a..0091328 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -62,11 +62,14 @@ import { type BandParamKind, type PeqEqUi, type PeqCatalogItem, + type PeqPresetLocalCache, type CatalogTarget, } from "./eq/constants"; import { eqInterp, formatBandFreqDisplay, + formatBandGainDisplay, + formatBandQDisplay, formatBandParamForInput, parseBandParamInput, normalizeFilterType, @@ -547,6 +550,12 @@ export default function EQPage() { const [saveBPresetName, setSaveBPresetName] = useState(""); const [bandParamDialog, setBandParamDialog] = useState(null); const [bandParamInput, setBandParamInput] = useState(""); + /** 滑块拖动时的实时显示(避免按钮文字滞后于 bands 状态) */ + const [bandSliderPreview, setBandSliderPreview] = useState<{ + freq?: number; + gain?: number; + q?: number; + } | null>(null); const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false); const [batchEditText, setBatchEditText] = useState(""); type BrandDrawerTab = "brands" | "models" | "target"; @@ -573,7 +582,7 @@ export default function EQPage() { const lastPeqCatalogSyncKeyRef = useRef(""); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); - const peqFiltersCacheRef = useRef>({}); + const peqPresetCacheRef = useRef>({}); const skipPeqAutoSyncRef = useRef(false); const skipBandsSyncFromAbToggleRef = useRef(false); const headphoneMenuRef = useRef(null); @@ -861,7 +870,7 @@ export default function EQPage() { preamp: parsed.preamp, filters, }; - rememberPeqFiltersForPreset(item.name, filters); + rememberPeqPresetCache(item.name, { filters, preamp: parsed.preamp, autoPre: 0 }); updatedPeq = merged; return merged; }) @@ -876,23 +885,32 @@ export default function EQPage() { } }; - const rememberPeqFiltersForPreset = useCallback( - (presetName: string | undefined, filters: PeqFilter[]) => { - if (!presetName || filters.length === 0) return; - peqFiltersCacheRef.current[presetName] = filters; + const rememberPeqPresetCache = useCallback( + (presetName: string | undefined, patch: PeqPresetLocalCache) => { + if (!presetName) return; + const prev = peqPresetCacheRef.current[presetName] ?? {}; + const next: PeqPresetLocalCache = { ...prev, ...patch }; + if (next.filters?.length === 0) delete next.filters; + peqPresetCacheRef.current[presetName] = next; }, [], ); - const forgetPeqFiltersForPreset = useCallback((presetName: string | undefined) => { + const forgetPeqPresetCache = useCallback((presetName: string | undefined) => { if (!presetName) return; - delete peqFiltersCacheRef.current[presetName]; + delete peqPresetCacheRef.current[presetName]; }, []); const mergeRemotePeqCatalog = useCallback((items: PeqCatalogItem[]) => { return items.map((item) => { - const cached = peqFiltersCacheRef.current[item.name]; - return cached?.length ? { ...item, filters: cached } : item; + const cached = peqPresetCacheRef.current[item.name]; + if (!cached) return item; + return { + ...item, + ...(cached.filters?.length ? { filters: cached.filters } : {}), + ...(cached.preamp !== undefined ? { preamp: cached.preamp } : {}), + ...(cached.autoPre !== undefined ? { autoPre: cached.autoPre } : {}), + }; }); }, []); @@ -962,13 +980,13 @@ export default function EQPage() { try { if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx); - forgetPeqFiltersForPreset(target.name); + forgetPeqPresetCache(target.name); applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) }); toast.success(eqUi.toastDeleted); return; } - forgetPeqFiltersForPreset(target.name); + forgetPeqPresetCache(target.name); await api.removePeq([target.name]); const latest = await api.getPeqState(); applyPeqStateToUI(latest); @@ -1323,20 +1341,38 @@ export default function EQPage() { }; }, [headphoneSwitchKey, loadRawCurveForPeq]); + useEffect(() => { + setBandSliderPreview(null); + }, [selectedBand, abMode, headphoneSwitchKey]); + type PeqSubmitTopLevel = "peqChange" | "peqApply" | "byMode"; - const persistFiltersToPeqItem = useCallback( - (catalogIdx: number, filtersSource: typeof DEFAULT_BANDS) => { + const persistPresetEditsToPeqItem = useCallback( + ( + catalogIdx: number, + filtersSource: typeof DEFAULT_BANDS, + meta?: { preamp?: number; autoPre?: number }, + ) => { const filters = filtersSource.map(bandToPeqFilter); setPeqItems((prev) => prev.map((item, i) => { if (i !== catalogIdx) return item; - rememberPeqFiltersForPreset(item.name, filters); - return { ...item, filters }; + const merged = { + ...item, + filters, + ...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}), + ...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}), + }; + rememberPeqPresetCache(item.name, { + filters, + preamp: merged.preamp, + autoPre: merged.autoPre, + }); + return merged; }), ); }, - [rememberPeqFiltersForPreset], + [rememberPeqPresetCache], ); const handleSelectHeadphone = useCallback( @@ -1346,10 +1382,16 @@ export default function EQPage() { return; } const leaving = peqItems[headphoneIdx]; - persistFiltersToPeqItem(headphoneIdx, bandsByMode.A); - if (leaving?.name) { - rememberPeqFiltersForPreset(leaving.name, bandsByMode.A.map(bandToPeqFilter)); - } + const leavingFilters = bandsByMode.A.map(bandToPeqFilter); + rememberPeqPresetCache(leaving?.name, { + filters: leavingFilters, + preamp: leaving?.preamp, + autoPre: leaving?.autoPre, + }); + persistPresetEditsToPeqItem(headphoneIdx, bandsByMode.A, { + preamp: leaving?.preamp, + autoPre: leaving?.autoPre, + }); if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); peqSyncTimerRef.current = null; @@ -1362,8 +1404,8 @@ export default function EQPage() { bandsByMode.A, headphoneIdx, peqItems, - persistFiltersToPeqItem, - rememberPeqFiltersForPreset, + persistPresetEditsToPeqItem, + rememberPeqPresetCache, updateSetting, ], ); @@ -1398,7 +1440,10 @@ export default function EQPage() { const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); if (usePeqChange) { - rememberPeqFiltersForPreset(peq.name, filters); + persistPresetEditsToPeqItem(idxAtSchedule, filtersSource, { + preamp: peq.preamp, + autoPre: peq.autoPre, + }); } const submit = usePeqChange ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) @@ -1408,7 +1453,7 @@ export default function EQPage() { }); }, delay); }, - [api, headphoneIdx, isDemoMode, rememberPeqFiltersForPreset, upgradePeqApply, upgradePeqChange], + [api, headphoneIdx, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange], ); /** 点击 A/B 切换试听:固定 peqApply */ @@ -1470,7 +1515,7 @@ export default function EQPage() { }, []); const chartRef = useRef(null); - const band = bands[selectedBand]; + const band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0]; // Render frequency response chart using ECharts const renderCharts = (peqFilters: typeof bands, raw: number[] | null, changeParam: boolean) => { @@ -1571,7 +1616,11 @@ export default function EQPage() { skipPeqAutoSyncRef.current = true; setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) })); setSelectedBandByMode((prev) => ({ ...prev, A: prev.B })); - rememberPeqFiltersForPreset(peq.name, filters); + rememberPeqPresetCache(peq.name, { + filters, + preamp: peq.preamp, + autoPre: peq.autoPre, + }); setPeqItems((prev) => prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })), ); @@ -1599,14 +1648,16 @@ export default function EQPage() { eqUi.toastApplyBSuccess, headphoneIdx, peqItems, - rememberPeqFiltersForPreset, + rememberPeqPresetCache, upgradePeqChange, ]); - const updateBand = (idx: number, patch: Partial) => { - const nextBands = bands.map((b, i) => (i === idx ? { ...b, ...patch } : b)); - setBands(nextBands); - }; + const updateBand = useCallback( + (idx: number, patch: Partial) => { + setBands((prevBands) => prevBands.map((b, i) => (i === idx ? { ...b, ...patch } : b))); + }, + [setBands], + ); const bandParamDialogMeta = useMemo(() => { if (!bandParamDialog) return null; @@ -1690,6 +1741,7 @@ export default function EQPage() { if (i !== headphoneIdx) return item; const merged = { ...item, ...patch }; updatedPeq = merged; + rememberPeqPresetCache(item.name, patch); return merged; }); return next; @@ -1862,7 +1914,12 @@ export default function EQPage() { background: "rgba(44,44,46,0.65)", }} onClick={() => setSelectedBand(i)}> - {freqLabel(b.freq)} + + {freqLabel(b.freq)} + {getFilterShortName(b.type)} ))} @@ -1934,18 +1991,23 @@ export default function EQPage() { updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} + onChange={(v) => { + const freq = Math.round(Math.pow(10, v)); + setBandSliderPreview((prev) => ({ ...prev, freq })); + updateBand(selectedBand, { freq }); + }} /> @@ -1956,18 +2018,22 @@ export default function EQPage() { updateBand(selectedBand, { gain: v })} + onChange={(v) => { + setBandSliderPreview((prev) => ({ ...prev, gain: v })); + updateBand(selectedBand, { gain: v }); + }} /> @@ -1978,18 +2044,22 @@ export default function EQPage() { updateBand(selectedBand, { q: v })} + onChange={(v) => { + setBandSliderPreview((prev) => ({ ...prev, q: v })); + updateBand(selectedBand, { q: v }); + }} /> diff --git a/client/src/pages/eq/constants.ts b/client/src/pages/eq/constants.ts index 823a25c..3e4a22b 100644 --- a/client/src/pages/eq/constants.ts +++ b/client/src/pages/eq/constants.ts @@ -52,6 +52,13 @@ export const DEFAULT_BANDS = [ /* ── PEQ catalog item type ── */ export type PeqCatalogItem = NonNullable[number]; +/** 本地编辑缓存(按预设名称);syncPeq 拉取时优先于设备 catalog */ +export type PeqPresetLocalCache = { + filters?: PeqFilter[]; + preamp?: number; + autoPre?: number; +}; + /* ── Flat preset filters ── */ export const FLAT_PRESET_FILTERS: PeqFilter[] = [ { type: 4, fc: 80, gain: 0, q: 0.1 }, diff --git a/client/src/pages/eq/utils.ts b/client/src/pages/eq/utils.ts index 50d6667..7b9735d 100644 --- a/client/src/pages/eq/utils.ts +++ b/client/src/pages/eq/utils.ts @@ -31,6 +31,18 @@ export function formatBandFreqDisplay(freq: number) { : `${freq} Hz`; } +export function formatBandGainDisplay(gain: unknown) { + const db = typeof gain === "number" && Number.isFinite(gain) ? gain : Number(gain); + if (!Number.isFinite(db)) return "0.0 dB"; + const text = db.toFixed(1); + return `${db >= 0 ? `+${text}` : text} dB`; +} + +export function formatBandQDisplay(q: unknown) { + const n = typeof q === "number" && Number.isFinite(q) ? q : Number(q); + return Number.isFinite(n) ? n.toFixed(2) : "—"; +} + /* ── Band param value for input field ── */ export function formatBandParamForInput( kind: BandParamKind, @@ -131,9 +143,19 @@ export function cloneBands( return source.map((band) => ({ ...band })); } -/* ── Frequency label for band grid ── */ -export function freqLabel(f: number) { - return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`; +/* ── Frequency label for band grid (numeric only; avoid browser translate → words) ── */ +export function freqLabel(freq: unknown) { + const hz = + typeof freq === "number" && Number.isFinite(freq) + ? freq + : Number(freq); + if (!Number.isFinite(hz)) return "—"; + if (hz >= 1000) { + const k = hz / 1000; + const text = k >= 10 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, ""); + return `${text}K`; + } + return String(Math.round(hz)); } /* ── Build PEQ catalog sync key ── */