refactor(eq): 更新EQ页面以优化预设缓存和滤波器管理
- 添加实时滑块预览状态以改善用户体验 - 重构预设缓存逻辑,使用新的PeqPresetLocalCache类型管理滤波器和增益设置 - 更新相关函数以支持新的预设缓存机制,减少重复计算 - 增强滤波器持久化逻辑,确保在耳机切换时正确保存和应用滤波器设置
This commit is contained in:
+115
-45
@@ -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<BandParamKind | null>(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<number | null>(null);
|
||||
const peqFiltersCacheRef = useRef<Record<string, PeqFilter[]>>({});
|
||||
const peqPresetCacheRef = useRef<Record<string, PeqPresetLocalCache>>({});
|
||||
const skipPeqAutoSyncRef = useRef(false);
|
||||
const skipBandsSyncFromAbToggleRef = useRef(false);
|
||||
const headphoneMenuRef = useRef<HTMLDivElement | null>(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<echarts.ECharts | null>(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<typeof bands[0]>) => {
|
||||
const nextBands = bands.map((b, i) => (i === idx ? { ...b, ...patch } : b));
|
||||
setBands(nextBands);
|
||||
};
|
||||
const updateBand = useCallback(
|
||||
(idx: number, patch: Partial<typeof DEFAULT_BANDS[0]>) => {
|
||||
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)}>
|
||||
<span className="text-[12px] font-bold leading-tight lg:text-[11px]">{freqLabel(b.freq)}</span>
|
||||
<span
|
||||
className="notranslate text-[12px] font-bold leading-tight tabular-nums lg:text-[11px]"
|
||||
translate="no"
|
||||
>
|
||||
{freqLabel(b.freq)}
|
||||
</span>
|
||||
<span className="text-[9px] mt-0.5 opacity-70 leading-tight lg:text-[8px] lg:mt-0">{getFilterShortName(b.type)}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -1934,18 +1991,23 @@ export default function EQPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBandParamDialog("freq")}
|
||||
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||
translate="no"
|
||||
>
|
||||
{formatBandFreqDisplay(band.freq)}
|
||||
{formatBandFreqDisplay(bandSliderPreview?.freq ?? band.freq)}
|
||||
</button>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={Math.log10(band.freq)}
|
||||
value={Math.log10(bandSliderPreview?.freq ?? band.freq)}
|
||||
min={Math.log10(BAND_FREQ_MIN)}
|
||||
max={Math.log10(BAND_FREQ_MAX)}
|
||||
step={0.005}
|
||||
onChange={(v) => 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 });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1956,18 +2018,22 @@ export default function EQPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBandParamDialog("gain")}
|
||||
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||
translate="no"
|
||||
>
|
||||
{band.gain >= 0 ? `+${band.gain.toFixed(1)}` : band.gain.toFixed(1)} dB
|
||||
{formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)}
|
||||
</button>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={band.gain}
|
||||
value={bandSliderPreview?.gain ?? band.gain}
|
||||
min={BAND_GAIN_MIN}
|
||||
max={BAND_GAIN_MAX}
|
||||
step={0.1}
|
||||
onChange={(v) => updateBand(selectedBand, { gain: v })}
|
||||
onChange={(v) => {
|
||||
setBandSliderPreview((prev) => ({ ...prev, gain: v }));
|
||||
updateBand(selectedBand, { gain: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1978,18 +2044,22 @@ export default function EQPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBandParamDialog("q")}
|
||||
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
|
||||
style={BAND_PARAM_VALUE_BOX_STYLE}
|
||||
translate="no"
|
||||
>
|
||||
{band.q.toFixed(2)}
|
||||
{formatBandQDisplay(bandSliderPreview?.q ?? band.q)}
|
||||
</button>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={band.q}
|
||||
value={bandSliderPreview?.q ?? band.q}
|
||||
min={BAND_Q_MIN}
|
||||
max={BAND_Q_MAX}
|
||||
step={0.01}
|
||||
onChange={(v) => updateBand(selectedBand, { q: v })}
|
||||
onChange={(v) => {
|
||||
setBandSliderPreview((prev) => ({ ...prev, q: v }));
|
||||
updateBand(selectedBand, { q: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,13 @@ export const DEFAULT_BANDS = [
|
||||
/* ── PEQ catalog item type ── */
|
||||
export type PeqCatalogItem = NonNullable<PeqState["peq"]>[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 },
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
Reference in New Issue
Block a user