优化 peq 流程

This commit is contained in:
eafonyang
2026-09-20 13:58:26 +08:00
parent eb00a417f7
commit 4a86114da6
6 changed files with 560 additions and 406 deletions
+305 -349
View File
@@ -183,6 +183,8 @@ export default function EQPage() {
const bandSliderDragRef = useRef<BandParamKind | null>(null);
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
const [batchEditText, setBatchEditText] = useState("");
const [batchSaving, setBatchSaving] = useState(false);
const batchSavingRef = useRef(false);
const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false);
const [brandDrawerKey, setBrandDrawerKey] = useState(0);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
@@ -213,47 +215,51 @@ export default function EQPage() {
commitGesture,
undo: popPeqUndo,
} = usePeqUndoHistory();
const allowPeqRemoteSyncRef = useRef(false);
const peqHydrationPendingRef = useRef(true);
const lastPeqCatalogSyncKeyRef = useRef("");
const syncingHeadphoneRef = useRef(false);
const peqSyncTimerRef = useRef<number | null>(null);
const pendingPeqSyncRef = useRef<{
name: string;
persist: boolean;
run: () => Promise<void>;
} | null>(null);
const inFlightPeqSyncRef = useRef<Promise<void>>(Promise.resolve());
const cancelPendingPeqSync = useCallback(() => {
pendingPeqSyncRef.current = null;
if (peqSyncTimerRef.current !== null) {
window.clearTimeout(peqSyncTimerRef.current);
peqSyncTimerRef.current = null;
}
}, []);
const flushPendingPeqSync = useCallback(() => {
const pending = pendingPeqSyncRef.current;
cancelPendingPeqSync();
if (pending?.persist) return pending.run();
return inFlightPeqSyncRef.current;
}, [cancelPendingPeqSync]);
useEffect(() => () => {
// SPA 离开页面时提交 A 的尾次编辑,取消未发送的 B 试听。
void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败"));
}, [flushPendingPeqSync]);
const peqPresetCacheRef = useRef<Record<string, PeqPresetLocalCache>>({});
/** Anchor UI selection by preset name so poll/catalog reorder won't mis-switch headphones. */
const selectedPeqNameRef = useRef("");
const devicePeqSelectRef = useRef(deviceState?.peqSelect);
devicePeqSelectRef.current = deviceState?.peqSelect;
const skipPeqAutoSyncRef = useRef(false);
const skipBandsSyncFromAbToggleRef = useRef(false);
const headphoneMenuRef = useRef<HTMLDivElement | null>(null);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
const bands = bandsByMode[abMode];
const bandsByModeRef = useRef(bandsByMode);
bandsByModeRef.current = bandsByMode;
const [bPeqMeta, setBPeqMeta] = useState({ preamp: 0, autoPre: 0 });
const bPeqMetaRef = useRef(bPeqMeta);
bPeqMetaRef.current = bPeqMeta;
const selectedBand = selectedBandByMode[abMode];
const setBandsForBothModes = useCallback(
(nextBands: typeof DEFAULT_BANDS) => {
const cloned = cloneBands(nextBands);
setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) });
},
[]
);
const setBands = useCallback(
(
updater:
| typeof DEFAULT_BANDS
| ((prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)
) => {
setBandsByMode(prev => ({
...prev,
[abMode]:
typeof updater === "function"
? (updater as (prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)(
prev[abMode]
)
: cloneBands(updater),
}));
},
[abMode]
);
const setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => {
const next = { A: cloneBands(nextBands), B: cloneBands(nextBands) };
bandsByModeRef.current = next;
setBandsByMode(next);
}, []);
const setSelectedBand = useCallback(
(updater: number | ((prev: number) => number)) => {
setSelectedBandByMode(prev => ({
@@ -267,25 +273,34 @@ export default function EQPage() {
[abMode]
);
const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => {
setBandsByMode(prev => ({ ...prev, [to]: cloneBands(prev[from]) }));
setSelectedBandByMode(prev => ({ ...prev, [to]: prev[from] }));
const next = { ...bandsByModeRef.current, [to]: cloneBands(bandsByModeRef.current[from]) };
bandsByModeRef.current = next;
setBandsByMode(next);
setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] }));
}, []);
const currentPeq = peqItems[headphoneIdx];
const preampValue = Number(currentPeq?.preamp ?? 0);
const autoPreOn = (currentPeq?.autoPre ?? 0) === 1;
const currentPeqPreamp = currentPeq?.preamp ?? 0;
const currentPeqAutoPre = currentPeq?.autoPre ?? 0;
const activePeq = currentPeq && (abMode === "B" ? { ...currentPeq, ...bPeqMeta } : currentPeq);
const activePeqRef = useRef(activePeq);
activePeqRef.current = activePeq;
const preampValue = Number(activePeq?.preamp ?? 0);
const autoPreOn = (activePeq?.autoPre ?? 0) === 1;
const undoSnapshotSourceRef = useRef({
abMode,
bandsByMode,
preamp: preampValue,
autoPre: autoPreOn ? 1 : 0,
metaByMode: { A: { preamp: currentPeqPreamp, autoPre: currentPeqAutoPre }, B: { ...bPeqMeta } },
});
undoSnapshotSourceRef.current = {
abMode,
bandsByMode,
preamp: preampValue,
autoPre: autoPreOn ? 1 : 0,
metaByMode: { A: { preamp: currentPeqPreamp, autoPre: currentPeqAutoPre }, B: { ...bPeqMeta } },
};
const takeUndoSnapshot = useCallback((): PeqUndoSnapshot => {
@@ -298,6 +313,7 @@ export default function EQPage() {
},
preamp: src.preamp,
autoPre: src.autoPre,
metaByMode: { A: { ...src.metaByMode.A }, B: { ...src.metaByMode.B } },
};
}, []);
@@ -369,7 +385,7 @@ export default function EQPage() {
Number(value.toFixed(3)).toString();
const buildBatchEditText = useCallback(() => {
const preamp = Number(currentPeq?.preamp ?? 0);
const preamp = preampValue;
const source = bands.length > 0 ? bands : DEFAULT_BANDS;
const lines = [`Preamp:${toCompactNumber(preamp)}dB`];
for (let i = 0; i < 10; i++) {
@@ -379,7 +395,7 @@ export default function EQPage() {
);
}
return lines.join("\n");
}, [bands, currentPeq?.preamp]);
}, [bands, preampValue]);
const openBatchEditDialog = () => {
setBatchEditText(buildBatchEditText());
@@ -473,48 +489,38 @@ export default function EQPage() {
[eqUi]
);
const handleSaveBatchEdit = () => {
const handleSaveBatchEdit = async () => {
if (batchSavingRef.current) return;
let parsed: ReturnType<typeof parseBatchEditText>;
try {
const parsed = parseBatchEditText(batchEditText);
captureBefore(takeUndoSnapshot());
let updatedPeq:
| { name: string; autoPre?: number; preamp?: number; canDel?: number }
| undefined;
setPeqItems(prev =>
prev.map((item, i) => {
if (i !== headphoneIdx) return item;
const filters = parsed.filters.map(bandToPeqFilter);
const merged = {
...item,
autoPre: 0,
preamp: parsed.preamp,
filters,
};
rememberPeqPresetCache(item.name, {
filters,
preamp: parsed.preamp,
autoPre: 0,
});
updatedPeq = merged;
return merged;
})
);
setBands(parsed.filters);
schedulePeqSync(
updatedPeq,
parsed.filters,
abMode,
0,
"byMode",
headphoneIdx
);
setSelectedBand(0);
parsed = parseBatchEditText(batchEditText);
} catch (error) {
toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError);
return;
}
const currentItem = activePeqRef.current;
if (!currentItem?.name) return;
batchSavingRef.current = true;
setBatchSaving(true);
captureBefore(takeUndoSnapshot());
const merged = { ...currentItem, autoPre: 0, preamp: parsed.preamp };
if (abMode === "B") {
bPeqMetaRef.current = { autoPre: 0, preamp: parsed.preamp };
setBPeqMeta(bPeqMetaRef.current);
}
activePeqRef.current = merged;
setBands(parsed.filters, false);
setSelectedBand(0);
try {
// 显式保存立即发送;A 持久化,B 仅试听。失败保留文本以便重试。
await schedulePeqSync(merged, parsed.filters, abMode, 0);
setIsBatchEditDialogOpen(false);
toast.success(eqUi.toastBatchApplied);
} catch (error) {
toast.error(
error instanceof Error ? error.message : eqUi.toastBatchParseError
);
} catch {
toast.error("EQ 保存失败");
} finally {
batchSavingRef.current = false;
setBatchSaving(false);
}
};
@@ -583,11 +589,9 @@ export default function EQPage() {
[mergeRemotePeqCatalog]
);
const applyPeqStateToUI = (remote: {
peq?: Array<{ name: string; filters?: any[] | string }>;
peqSelect?: number;
filters?: PeqFilter[];
}) => {
const applyPeqStateToUI = (
remote: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number; filters?: PeqFilter[] },
) => {
const resolvedPeqSelect =
remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect ?? 0;
@@ -598,13 +602,13 @@ export default function EQPage() {
peqSelect: resolvedPeqSelect,
});
}
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(
remote,
resolvedPeqSelect
);
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, resolvedPeqSelect);
const catalogIdx = syncPeqCatalogFromState(remote, resolvedPeqSelect);
const items = remote.peq ?? [];
const items = mergeRemotePeqCatalog(remote.peq ?? []);
const meta = { preamp: items[catalogIdx]?.preamp ?? 0, autoPre: items[catalogIdx]?.autoPre ?? 0 };
bPeqMetaRef.current = meta;
setBPeqMeta(meta);
if (items.length === 0) {
setBandsForBothModes(FLAT_BANDS);
setSelectedBandByMode({ A: 0, B: 0 });
@@ -612,7 +616,7 @@ export default function EQPage() {
}
const nextBands = normalizeFiltersFromPeq(
items[catalogIdx] as { filters?: any[] | string }
items[catalogIdx] as { filters?: any[] | string },
);
if (nextBands.length > 0) {
setBandsForBothModes(nextBands);
@@ -632,6 +636,7 @@ export default function EQPage() {
setDeleteHeadphoneDialog(null);
try {
await flushPendingPeqSync();
if (isDemoMode || !api) {
const nextItems = peqItems.filter((_, idx) => idx !== target.idx);
forgetPeqPresetCache(target.name);
@@ -702,8 +707,8 @@ export default function EQPage() {
model: copyModel || undefined,
target: copyTarget || undefined,
form: copyForm || undefined,
autoPre: currentPeq?.autoPre,
preamp: currentPeq?.preamp,
autoPre: activePeq?.autoPre,
preamp: activePeq?.preamp,
canDel: currentPeq?.canDel,
},
bands.map(bandToPeqFilter)
@@ -718,6 +723,7 @@ export default function EQPage() {
};
try {
await flushPendingPeqSync();
await upgradePeqChange(
addPresetMode === "copy" ? copyPayload : flatPayload
);
@@ -741,9 +747,9 @@ export default function EQPage() {
? bands.map(bandToPeqFilter)
: FLAT_PRESET_FILTERS;
const localAutoPre =
addPresetMode === "copy" ? (currentPeq?.autoPre ?? 0) : 0;
addPresetMode === "copy" ? (activePeq?.autoPre ?? 0) : 0;
const localPreamp =
addPresetMode === "copy" ? (currentPeq?.preamp ?? 0) : 0;
addPresetMode === "copy" ? (activePeq?.preamp ?? 0) : 0;
setHeadphoneModels(prev => [...prev, nextName]);
setPeqItems(prev => [
...prev,
@@ -799,8 +805,8 @@ export default function EQPage() {
model: currentPeq?.model,
target: currentPeq?.target,
form: currentPeq?.form,
autoPre: currentPeq?.autoPre,
preamp: currentPeq?.preamp,
autoPre: bPeqMeta?.autoPre,
preamp: bPeqMeta?.preamp,
canDel: currentPeq?.canDel,
},
filters
@@ -808,6 +814,7 @@ export default function EQPage() {
};
try {
await flushPendingPeqSync();
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "saveBAsPreset");
@@ -830,8 +837,8 @@ export default function EQPage() {
{
name: nextName,
filters,
autoPre: currentPeq?.autoPre ?? 0,
preamp: currentPeq?.preamp ?? 0,
autoPre: bPeqMeta?.autoPre ?? 0,
preamp: bPeqMeta?.preamp ?? 0,
canDel: currentPeq?.canDel ?? 1,
},
]);
@@ -898,8 +905,8 @@ export default function EQPage() {
),
};
skipPeqAutoSyncRef.current = true;
try {
await flushPendingPeqSync();
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "importSharedEq");
@@ -956,7 +963,6 @@ export default function EQPage() {
toast.success(eqUi.importSuccess);
setIsShareDialogOpen(false);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail);
}
};
@@ -984,85 +990,74 @@ export default function EQPage() {
let cancelled = false;
async function loadHeadphones() {
peqHydrationPendingRef.current = true;
allowPeqRemoteSyncRef.current = false;
if (isDemoMode || !api) {
if (cancelled) return;
// 演示模式使用默认列表
const defaultModels = [
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
selectHeadphone(0, defaultModels[0]);
setCurrentRawCurve(null);
return;
}
try {
if (isDemoMode || !api) {
if (cancelled) return;
// 演示模式使用默认列表
const defaultModels = [
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
selectHeadphone(0, defaultModels[0]);
setCurrentRawCurve(null);
return;
}
try {
const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones");
if (cancelled) return;
if (loadedPeq.peq && loadedPeq.peq.length > 0) {
const mergedPeq = mergeRemotePeqCatalog(loadedPeq.peq);
setHeadphoneModels(mergedPeq.map(h => h.name));
setPeqItems(mergedPeq);
const nextIdx = Math.min(
Math.max(
devicePeqSelectRef.current ?? loadedPeq.peqSelect ?? 0,
0
),
mergedPeq.length - 1
);
selectHeadphone(nextIdx, mergedPeq[nextIdx]?.name);
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(
loadedPeq,
devicePeqSelectRef.current ?? loadedPeq.peqSelect
);
const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones");
if (cancelled) return;
if (loadedPeq.peq && loadedPeq.peq.length > 0) {
const mergedPeq = mergeRemotePeqCatalog(loadedPeq.peq);
setHeadphoneModels(mergedPeq.map(h => h.name));
setPeqItems(mergedPeq);
const nextIdx = Math.min(
Math.max(
devicePeqSelectRef.current ?? loadedPeq.peqSelect ?? 0,
0
),
mergedPeq.length - 1
);
selectHeadphone(nextIdx, mergedPeq[nextIdx]?.name);
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(
loadedPeq,
devicePeqSelectRef.current ?? loadedPeq.peqSelect
);
// 初始化当前选中的耳机曲线
const currentPeq = mergedPeq[nextIdx];
if (currentPeq) {
// 修复 filters 数据(兼容不同的字段名)
const fixedFilters = normalizeFiltersFromPeq(currentPeq);
// 初始化当前选中的耳机曲线
const currentPeq = mergedPeq[nextIdx];
if (currentPeq) {
// 修复 filters 数据(兼容不同的字段名)
const fixedFilters = normalizeFiltersFromPeq(currentPeq);
// 更新 bands 状态
if (fixedFilters && fixedFilters.length > 0) {
setBandsForBothModes(fixedFilters);
setSelectedBandByMode({ A: 0, B: 0 });
}
// raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理,
// 避免首次进入页面时重复请求 modelCurve。
// 更新 bands 状态
if (fixedFilters && fixedFilters.length > 0) {
setBandsForBothModes(fixedFilters);
setSelectedBandByMode({ A: 0, B: 0 });
}
} else {
// 设备未返回任何 EQ 预设:保持列表为空,避免展示虚假的默认型号
setHeadphoneModels([]);
setPeqItems([]);
selectHeadphone(0, "");
setBandsForBothModes(FLAT_BANDS);
setSelectedBandByMode({ A: 0, B: 0 });
setCurrentRawCurve(null);
// raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理,
// 避免首次进入页面时重复请求 modelCurve。
}
} catch {
if (cancelled) return;
// 拉取失败:同样保持空列表,不展示虚假的默认型号
} else {
// 设备未返回任何 EQ 预设:保持列表为空,避免展示虚假的默认型号
setHeadphoneModels([]);
setPeqItems([]);
selectHeadphone(0, "");
setBandsForBothModes(FLAT_BANDS);
setSelectedBandByMode({ A: 0, B: 0 });
setCurrentRawCurve(null);
}
} finally {
} catch {
if (cancelled) return;
allowPeqRemoteSyncRef.current = true;
skipPeqAutoSyncRef.current = true;
queueMicrotask(() => {
if (!cancelled) peqHydrationPendingRef.current = false;
});
// 拉取失败:同样保持空列表,不展示虚假的默认型号
setHeadphoneModels([]);
setPeqItems([]);
selectHeadphone(0, "");
setCurrentRawCurve(null);
}
}
void loadHeadphones();
@@ -1103,6 +1098,9 @@ export default function EQPage() {
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
useEffect(() => {
const peq = peqItems[headphoneIdx];
const meta = { preamp: peq?.preamp ?? 0, autoPre: peq?.autoPre ?? 0 };
bPeqMetaRef.current = meta;
setBPeqMeta(meta);
if (!peq) {
// 列表为空(如设备端删光全部预设):恢复平直曲线并清空 Raw 参考线
setBandsForBothModes(FLAT_BANDS);
@@ -1113,8 +1111,6 @@ export default function EQPage() {
}
const nextBands = normalizeFiltersFromPeq(peq);
if (!nextBands.length) return;
syncingHeadphoneRef.current = true;
skipPeqAutoSyncRef.current = true;
setBandsForBothModes(nextBands);
setSelectedBandByMode({ A: 0, B: 0 });
const shouldFetchModelCurve = !!(peq.brand?.trim() && peq.model?.trim());
@@ -1134,9 +1130,6 @@ export default function EQPage() {
setCurrentRawCurve(raw);
setRawCurveLoading(false);
})();
requestAnimationFrame(() => {
syncingHeadphoneRef.current = false;
});
return () => {
cancelled = true;
};
@@ -1150,66 +1143,40 @@ export default function EQPage() {
const persistPresetEditsToPeqItem = useCallback(
(
catalogIdx: number,
name: string,
filtersSource: typeof DEFAULT_BANDS,
meta?: { preamp?: number; autoPre?: number }
meta?: { preamp?: number; autoPre?: number },
) => {
const filters = filtersSource.map(bandToPeqFilter);
setPeqItems(prev =>
prev.map((item, i) => {
if (i !== catalogIdx) return item;
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;
})
);
const patch = {
filters: filtersSource.map(bandToPeqFilter),
...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}),
...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}),
};
// 在事件中记录快照;不依赖 updater 的执行时机,也不按可能变化的下标回写。
rememberPeqPresetCache(name, patch);
setPeqItems((prev) => prev.map((item) => (item.name === name ? { ...item, ...patch } : item)));
},
[rememberPeqPresetCache]
[rememberPeqPresetCache],
);
const handleSelectHeadphone = useCallback(
(idx: number) => {
async (idx: number) => {
if (idx === headphoneIdx) {
setIsHeadphoneMenuOpen(false);
return;
}
const leaving = peqItems[headphoneIdx];
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;
const target = peqItems[idx];
if (!target) return;
try {
await flushPendingPeqSync();
await updateSetting({ peqSelect: idx });
selectHeadphone(idx, target.name);
setIsHeadphoneMenuOpen(false);
} catch {
toast.error("EQ 保存失败");
}
selectHeadphone(idx, peqItems[idx]?.name);
updateSetting({ peqSelect: idx });
setIsHeadphoneMenuOpen(false);
},
[
bandsByMode.A,
headphoneIdx,
peqItems,
persistPresetEditsToPeqItem,
rememberPeqPresetCache,
selectHeadphone,
updateSetting,
]
[flushPendingPeqSync, headphoneIdx, peqItems, selectHeadphone, updateSetting],
);
const schedulePeqSync = useCallback(
@@ -1230,107 +1197,115 @@ export default function EQPage() {
mode: "A" | "B",
delay = 320,
topLevel: PeqSubmitTopLevel = "byMode",
catalogIdx = headphoneIdx
) => {
if (!allowPeqRemoteSyncRef.current) return;
if (peqHydrationPendingRef.current) return;
if (!isDemoMode && !api) return;
if (!peq?.name) return;
if (peqSyncTimerRef.current !== null) {
window.clearTimeout(peqSyncTimerRef.current);
): Promise<void> => {
if (!peq?.name || (!isDemoMode && !api)) return Promise.reject(new Error("EQ unavailable"));
const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A");
const pending = pendingPeqSyncRef.current;
if (pending?.persist && (!usePeqChange || pending.name !== peq.name)) {
void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败"));
} else {
cancelPendingPeqSync();
}
const idxAtSchedule = catalogIdx;
const body = buildPeqPresetBody(peq, filtersSource.map(bandToPeqFilter));
if (usePeqChange) persistPresetEditsToPeqItem(peq.name, filtersSource, peq);
const runSync = () => {
pendingPeqSyncRef.current = null;
peqSyncTimerRef.current = null;
const request = usePeqChange
? upgradePeqChange({ peqChange: body })
: upgradePeqApply({ peqApply: body });
inFlightPeqSyncRef.current = request;
return request;
};
if (delay === 0) return runSync();
pendingPeqSyncRef.current = { name: peq.name, persist: usePeqChange, run: runSync };
peqSyncTimerRef.current = window.setTimeout(() => {
const filters = filtersSource.map(bandToPeqFilter);
const body = buildPeqPresetBody(peq, filters);
const usePeqChange =
topLevel === "peqChange" || (topLevel === "byMode" && mode === "A");
if (usePeqChange) {
persistPresetEditsToPeqItem(idxAtSchedule, filtersSource, {
preamp: peq.preamp,
autoPre: peq.autoPre,
});
}
const submit = usePeqChange
? () =>
upgradePeqChange({ peqChange: body } satisfies PeqChangePayload)
: () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload);
submit().catch(() => {
toast.error("EQ 保存失败");
});
void runSync().catch(() => toast.error("EQ 保存失败"));
}, delay);
return Promise.resolve();
},
[
api,
headphoneIdx,
cancelPendingPeqSync,
flushPendingPeqSync,
isDemoMode,
persistPresetEditsToPeqItem,
upgradePeqApply,
upgradePeqChange,
]
],
);
const setBands = useCallback(
(
updater: typeof DEFAULT_BANDS | ((prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS),
sync = true,
) => {
const prev = bandsByModeRef.current;
const nextBands = cloneBands(typeof updater === "function" ? updater(prev[abMode]) : updater);
const next = { ...prev, [abMode]: nextBands };
bandsByModeRef.current = next;
setBandsByMode(next);
// 只由用户编辑触发提交,初始化/轮询/切换回显不再依赖 skip 标记。
if (sync)
void schedulePeqSync(activePeqRef.current, nextBands, abMode).catch(() =>
toast.error("EQ 保存失败"),
);
},
[abMode, schedulePeqSync],
);
/** 点击 A/B 切换试听:固定 peqApply */
const handleAbToggle = useCallback(
(m: "A" | "B") => {
if (m === abMode) return;
skipBandsSyncFromAbToggleRef.current = true;
// 切换试听前补发未完成的持久化同步,避免被随后的 peqApply 覆盖丢失
void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败"));
setAbMode(m);
schedulePeqSync(peqItems[headphoneIdx], bandsByMode[m], m, 0, "peqApply");
const peq = peqItems[headphoneIdx];
const meta = m === "B" ? bPeqMetaRef.current : peq;
if (peq)
void schedulePeqSync({ ...peq, ...meta }, bandsByModeRef.current[m], m, 0, "peqApply").catch(
() => toast.error("EQ 保存失败"),
);
},
[abMode, bandsByMode, headphoneIdx, peqItems, schedulePeqSync]
[abMode, bandsByMode, flushPendingPeqSync, headphoneIdx, peqItems, schedulePeqSync],
);
const handleCopyAndSwitchTo = useCallback(
(to: "A" | "B") => {
const from = abMode;
captureBefore(takeUndoSnapshot());
const copied = cloneBands(bandsByMode[from]);
// 切换试听前补发未完成的持久化同步,避免被随后的 peqApply 覆盖丢失
void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败"));
const copied = cloneBands(bandsByModeRef.current[from]);
const peq = activePeqRef.current;
if (!peq) return;
const meta = { preamp: peq.preamp ?? 0, autoPre: peq.autoPre ?? 0 };
if (to === "B") {
bPeqMetaRef.current = meta;
setBPeqMeta(meta);
} else {
setPeqItems((prev) =>
prev.map((item) => (item.name === peq.name ? { ...item, ...meta } : item)),
);
}
copyModeParams(from, to);
skipBandsSyncFromAbToggleRef.current = true;
setAbMode(to);
schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply");
void schedulePeqSync(peq, copied, to, 0, "peqApply").catch(() => toast.error("EQ 保存失败"));
},
[
abMode,
bandsByMode,
captureBefore,
copyModeParams,
flushPendingPeqSync,
headphoneIdx,
peqItems,
schedulePeqSync,
takeUndoSnapshot,
]
],
);
// 参数编辑防抖:A→peqChange,B→peqApply
useEffect(() => {
if (peqHydrationPendingRef.current) return;
if (syncingHeadphoneRef.current) return;
if (skipPeqAutoSyncRef.current) {
skipPeqAutoSyncRef.current = false;
return;
}
if (skipBandsSyncFromAbToggleRef.current) {
skipBandsSyncFromAbToggleRef.current = false;
return;
}
schedulePeqSync(
peqItems[headphoneIdx],
bands,
abMode,
320,
"byMode",
headphoneIdx
);
return () => {
if (peqSyncTimerRef.current !== null) {
window.clearTimeout(peqSyncTimerRef.current);
}
};
}, [bands, abMode, headphoneIdx, schedulePeqSync]);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
@@ -1352,7 +1327,8 @@ export default function EQPage() {
bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
const handleApplyB = useCallback(async () => {
const peq = peqItems[headphoneIdx];
const item = peqItems[headphoneIdx];
const peq = item && { ...item, ...bPeqMetaRef.current };
if (!peq?.name) {
toast.error(eqUi.toastApplyBFail);
return;
@@ -1374,33 +1350,19 @@ export default function EQPage() {
preamp: peq.preamp,
canDel: peq.canDel,
},
filters
filters,
),
};
skipPeqAutoSyncRef.current = true;
setBandsByMode(prev => ({ ...prev, A: cloneBands(bBands) }));
setSelectedBandByMode(prev => ({ ...prev, A: prev.B }));
rememberPeqPresetCache(peq.name, {
filters,
preamp: peq.preamp,
autoPre: peq.autoPre,
});
setPeqItems(prev =>
prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters }))
);
const next = { ...bandsByModeRef.current, A: cloneBands(bBands) };
bandsByModeRef.current = next;
setBandsByMode(next);
setSelectedBandByMode((prev) => ({ ...prev, A: prev.B }));
setAbMode("A");
if (peqSyncTimerRef.current !== null) {
window.clearTimeout(peqSyncTimerRef.current);
peqSyncTimerRef.current = null;
}
try {
await upgradePeqChange(payload);
await schedulePeqSync(payload.peqChange, bBands, "A", 0, "peqChange");
toast.success(eqUi.toastApplyBSuccess);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.toastApplyBFail);
}
}, [
@@ -1410,9 +1372,8 @@ export default function EQPage() {
eqUi.toastApplyBSuccess,
headphoneIdx,
peqItems,
rememberPeqPresetCache,
schedulePeqSync,
takeUndoSnapshot,
upgradePeqChange,
]);
const updateBand = useCallback(
@@ -1553,23 +1514,18 @@ export default function EQPage() {
closeBandParamDialog();
};
const updateCurrentPeqMeta = (
patch: Partial<{ autoPre: number; preamp: number }>
) => {
let updatedPeq:
| { name: string; autoPre?: number; preamp?: number; canDel?: number }
| undefined;
setPeqItems(prev => {
const next = prev.map((item, i) => {
if (i !== headphoneIdx) return item;
const merged = { ...item, ...patch };
updatedPeq = merged;
rememberPeqPresetCache(item.name, patch);
return merged;
});
return next;
});
schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx);
const updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => {
const item = activePeqRef.current;
if (!item) return;
const merged = { ...item, ...patch };
activePeqRef.current = merged;
if (abMode === "B") {
bPeqMetaRef.current = { preamp: merged.preamp ?? 0, autoPre: merged.autoPre ?? 0 };
setBPeqMeta(bPeqMetaRef.current);
}
void schedulePeqSync(merged, bandsByModeRef.current[abMode], abMode).catch(() =>
toast.error("EQ 保存失败"),
);
};
const beginPreampSliderDrag = () => {
@@ -1590,33 +1546,29 @@ export default function EQPage() {
const snap = popPeqUndo();
if (!snap) return;
const toastMsg = formatPeqUndoToast(current, snap, eqUi);
const item = peqItems[headphoneIdx];
if (!item) return;
const next = { A: cloneBands(snap.bandsByMode.A), B: cloneBands(snap.bandsByMode.B) };
const aMeta = snap.metaByMode?.A ?? { preamp: snap.preamp, autoPre: snap.autoPre };
const bMeta = snap.metaByMode?.B ?? { preamp: snap.preamp, autoPre: snap.autoPre };
bandsByModeRef.current = next;
bPeqMetaRef.current = bMeta;
setBandsByMode(next);
setBPeqMeta(bMeta);
setAbMode(snap.abMode);
setBandsByMode({
A: cloneBands(
snap.bandsByMode.A as unknown as typeof DEFAULT_BANDS
),
B: cloneBands(
snap.bandsByMode.B as unknown as typeof DEFAULT_BANDS
),
});
setPeqItems(prev =>
prev.map((item, i) => {
if (i !== headphoneIdx) return item;
const filters = snap.bandsByMode.A.map(bandToPeqFilter);
const merged = {
...item,
preamp: snap.preamp,
autoPre: snap.autoPre,
filters,
};
rememberPeqPresetCache(item.name, {
filters,
preamp: snap.preamp,
autoPre: snap.autoPre,
});
return merged;
})
);
const aChanged =
JSON.stringify(current.bandsByMode.A) !== JSON.stringify(next.A) ||
JSON.stringify(current.metaByMode?.A) !== JSON.stringify(aMeta);
if (aChanged || snap.abMode === "A") {
void schedulePeqSync({ ...item, ...aMeta }, next.A, "A", 0).catch(() =>
toast.error("EQ 保存失败"),
);
}
if (snap.abMode === "B") {
void schedulePeqSync({ ...item, ...bMeta }, next.B, "B", 0).catch(() =>
toast.error("EQ 保存失败"),
);
}
toast.success(toastMsg);
};
@@ -2119,7 +2071,8 @@ export default function EQPage() {
text={batchEditText}
onChangeText={setBatchEditText}
onSave={handleSaveBatchEdit}
onClose={() => setIsBatchEditDialogOpen(false)}
saving={batchSaving}
onClose={() => { if (!batchSavingRef.current) setIsBatchEditDialogOpen(false); }}
eqUi={eqUi}
/>
@@ -2132,6 +2085,7 @@ export default function EQPage() {
eqUi={eqUi}
onDeletePresets={async (names) => {
try {
await flushPendingPeqSync();
if (isDemoMode || !api) {
const nextItems = peqItems.filter(it => !names.includes(it.name));
names.forEach(n => forgetPeqPresetCache(n));
@@ -2155,6 +2109,8 @@ export default function EQPage() {
}}
onRenamePreset={async (oldName, newName, item) => {
try {
await flushPendingPeqSync();
item = { ...item, ...peqPresetCacheRef.current[oldName] };
if (selectedPeqNameRef.current === oldName) {
selectedPeqNameRef.current = newName;
}
@@ -2212,6 +2168,7 @@ export default function EQPage() {
}}
onConfirmOrder={async (names, peqSelect) => {
try {
await flushPendingPeqSync();
if (isDemoMode || !api) {
const byName = new Map(peqItems.map(it => [it.name, it]));
const nextItems = names
@@ -2300,8 +2257,8 @@ export default function EQPage() {
const existingIdx = headphoneModels.indexOf(createdName);
const isOverwrite = existingIdx >= 0;
if (!isOverwrite && !guardPeqPresetCapacity()) return;
skipPeqAutoSyncRef.current = true;
try {
await flushPendingPeqSync();
await upgradePeqChange(postPeq);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "addCatalogPreset");
@@ -2362,7 +2319,6 @@ export default function EQPage() {
}
setIsBrandDrawerOpen(false);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.toastAddPresetFail);
}
};
@@ -6,6 +6,7 @@ export function BatchEditDialog({
text,
onChangeText,
onSave,
saving = false,
onClose,
eqUi,
}: {
@@ -13,6 +14,7 @@ export function BatchEditDialog({
text: string;
onChangeText: (v: string) => void;
onSave: () => void;
saving?: boolean;
onClose: () => void;
eqUi: PeqEqUi;
}) {
@@ -34,6 +36,7 @@ export function BatchEditDialog({
type="button"
className="text-white/45 hover:text-white/75 transition-colors"
onClick={onClose}
disabled={saving}
>
<X size={20} />
</button>
@@ -43,6 +46,7 @@ export function BatchEditDialog({
</p>
<textarea
value={text}
disabled={saving}
onChange={(e) => onChangeText(e.target.value)}
spellCheck={false}
className="h-[360px] w-full resize-none overflow-auto rounded-[10px] border border-white/12 bg-[#15171b] p-3 font-mono text-[13px] leading-6 text-white/90 outline-none focus:border-[#00FFF6]/55"
@@ -52,6 +56,7 @@ export function BatchEditDialog({
type="button"
className="px-5 py-2 rounded-full text-[24px] text-white/80 bg-[#3f4349] hover:bg-[#4a4f56] transition-colors"
onClick={onClose}
disabled={saving}
>
{eqUi.cancel}
</button>
@@ -59,6 +64,8 @@ export function BatchEditDialog({
type="button"
className="px-5 py-2 rounded-full text-[24px] text-black bg-[#00FFF6] hover:brightness-95 transition-all"
onClick={onSave}
disabled={saving}
aria-busy={saving}
>
{eqUi.save}
</button>
@@ -16,6 +16,7 @@ export type PeqUndoSnapshot = {
bandsByMode: { A: PeqUndoBand[]; B: PeqUndoBand[] };
preamp: number;
autoPre: number;
metaByMode?: Record<"A" | "B", { preamp: number; autoPre: number }>;
};
function cloneSnapshot(snapshot: PeqUndoSnapshot): PeqUndoSnapshot {
@@ -27,6 +28,7 @@ function cloneSnapshot(snapshot: PeqUndoSnapshot): PeqUndoSnapshot {
},
preamp: snapshot.preamp,
autoPre: snapshot.autoPre,
...(snapshot.metaByMode ? { metaByMode: { A: { ...snapshot.metaByMode.A }, B: { ...snapshot.metaByMode.B } } } : {}),
};
}
@@ -36,6 +38,7 @@ export function peqUndoSnapshotsEqual(
): boolean {
if (a.abMode !== b.abMode) return false;
if (a.preamp !== b.preamp || a.autoPre !== b.autoPre) return false;
if (JSON.stringify(a.metaByMode) !== JSON.stringify(b.metaByMode)) return false;
return (
JSON.stringify(a.bandsByMode.A) === JSON.stringify(b.bandsByMode.A) &&
JSON.stringify(a.bandsByMode.B) === JSON.stringify(b.bandsByMode.B)