From f4c9579e4f159e429a69d99a2ea5ae2b9b81cfbc Mon Sep 17 00:00:00 2001
From: eafonyang
Date: Sun, 20 Sep 2026 13:58:54 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20peq=20=E6=B5=81=E7=A8=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
client/src/contexts/DeviceContext.tsx | 4 +-
client/src/lib/luxsinApi.ts | 133 ++--
client/src/pages/EQPage.tsx | 654 ++++++++----------
.../pages/eq/components/BatchEditDialog.tsx | 7 +
.../src/pages/eq/hooks/usePeqUndoHistory.ts | 3 +
scripts/eq-save-regression.test.mjs | 165 +++++
6 files changed, 560 insertions(+), 406 deletions(-)
create mode 100644 scripts/eq-save-regression.test.mjs
diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx
index 7bead87..527f9cf 100644
--- a/client/src/contexts/DeviceContext.tsx
+++ b/client/src/contexts/DeviceContext.tsx
@@ -195,7 +195,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
applyPeqFiltersToState(payload.peqChange.filters);
return;
}
- if (!api) return;
+ if (!api) throw new Error("EQ unavailable");
await api.upgradePeqChange(payload);
applyPeqFiltersToState(payload.peqChange.filters);
}, [api, isDemoMode, applyPeqFiltersToState]);
@@ -205,7 +205,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
applyPeqFiltersToState(payload.peqApply.filters);
return;
}
- if (!api) return;
+ if (!api) throw new Error("EQ unavailable");
await api.upgradePeqApply(payload);
applyPeqFiltersToState(payload.peqApply.filters);
}, [api, isDemoMode, applyPeqFiltersToState]);
diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts
index 1d94d9d..4118e32 100644
--- a/client/src/lib/luxsinApi.ts
+++ b/client/src/lib/luxsinApi.ts
@@ -295,6 +295,14 @@ function normalizeDeviceHost(input: string): string {
// ============================================================
export class LuxsinAPI {
private baseUrl: string;
+ private peqRequestQueue: Promise = Promise.resolve();
+
+ /** EQ 写入、试听、选择和目录读取按调用顺序执行;单次失败不阻塞后续重试。 */
+ private enqueuePeqRequest(request: () => Promise): Promise {
+ const result = this.peqRequestQueue.then(request);
+ this.peqRequestQueue = result.then(() => undefined, () => undefined);
+ return result;
+ }
constructor(ip: string) {
const host = normalizeDeviceHost(ip);
@@ -339,17 +347,25 @@ export class LuxsinAPI {
}
async setSetting(params: Record): Promise {
- const query = Object.entries(params)
- .map(([k, v]) => `${k}=${v}`)
- .join("&");
- await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
+ const request = async () => {
+ const query = Object.entries(params)
+ .map(([k, v]) => `${k}=${v}`)
+ .join("&");
+ const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
+ if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`);
+ };
+ if (params.peqSelect !== undefined) return this.enqueuePeqRequest(request);
+ return request();
}
async getPeqState(): Promise {
- const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
- const text = await response.text();
- const decoded = decodeCustomBase64(text.trim());
- return JSON.parse(decoded) as PeqState;
+ return this.enqueuePeqRequest(async () => {
+ const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
+ if (!response.ok) throw new Error(`PEQ read failed: ${response.status}`);
+ const text = await response.text();
+ const decoded = decodeCustomBase64(text.trim());
+ return JSON.parse(decoded) as PeqState;
+ });
}
async setPeqFilters(filters: PeqFilter[]): Promise {
@@ -368,69 +384,76 @@ export class LuxsinAPI {
/** POST `json=` — matches legacy axios `upgradePeq`. */
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise {
- if (import.meta.env.DEV) {
- const action =
- "peqChange" in body ? "peqChange" : "peqApply" in body ? "peqApply" : "peq";
- console.log(`[dev/info.cgi] POST ${action}`, body);
- }
- const encoded = encodeCustomBase64(JSON.stringify(body));
- const form = new URLSearchParams();
- form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
- body: form.toString(),
+ return this.enqueuePeqRequest(async () => {
+ if (import.meta.env.DEV) {
+ const action = "peqChange" in body ? "peqChange" : "peqApply" in body ? "peqApply" : "peq";
+ console.log(`[dev/info.cgi] POST ${action}`, body);
+ }
+ const encoded = encodeCustomBase64(JSON.stringify(body));
+ const form = new URLSearchParams();
+ form.set("json", encoded);
+ const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
+ body: form.toString(),
+ });
+ if (!response.ok) {
+ throw new Error(`PEQ request failed: ${response.status}`);
+ }
});
- if (!response.ok) {
- throw new Error(`PEQ request failed: ${response.status}`);
- }
}
/** Remove one or more headphone PEQ profiles. */
async removePeq(names: string[]): Promise {
- const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
- const form = new URLSearchParams();
- form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
- body: form.toString(),
+ return this.enqueuePeqRequest(async () => {
+ const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
+ const form = new URLSearchParams();
+ form.set("json", encoded);
+ const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
+ body: form.toString(),
+ });
+ if (!response.ok) {
+ throw new Error(`PEQ remove failed: ${response.status}`);
+ }
});
- if (!response.ok) {
- throw new Error(`PEQ remove failed: ${response.status}`);
- }
}
/** Rename a headphone PEQ profile. */
async renamePeq(oldName: string, newName: string): Promise {
- const payload: PeqRenamePayload = { peqRename: { oldName, newName } };
- const encoded = encodeCustomBase64(JSON.stringify(payload));
- const form = new URLSearchParams();
- form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
- body: form.toString(),
+ return this.enqueuePeqRequest(async () => {
+ const payload: PeqRenamePayload = { peqRename: { oldName, newName } };
+ const encoded = encodeCustomBase64(JSON.stringify(payload));
+ const form = new URLSearchParams();
+ form.set("json", encoded);
+ const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
+ body: form.toString(),
+ });
+ if (!response.ok) {
+ throw new Error(`PEQ rename failed: ${response.status}`);
+ }
});
- if (!response.ok) {
- throw new Error(`PEQ rename failed: ${response.status}`);
- }
}
/** Reorder headphone PEQ profiles. */
async sortPeq(names: string[], peqSelect: number): Promise {
- const payload: PeqSoftPayload = { peqSoft: names, peqSelect };
- const encoded = encodeCustomBase64(JSON.stringify(payload));
- const form = new URLSearchParams();
- form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
- body: form.toString(),
+ return this.enqueuePeqRequest(async () => {
+ const payload: PeqSoftPayload = { peqSoft: names, peqSelect };
+ const encoded = encodeCustomBase64(JSON.stringify(payload));
+ const form = new URLSearchParams();
+ form.set("json", encoded);
+ const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
+ body: form.toString(),
+ });
+ if (!response.ok) {
+ throw new Error(`PEQ sort failed: ${response.status}`);
+ }
});
- if (!response.ok) {
- throw new Error(`PEQ sort failed: ${response.status}`);
- }
}
setVolume(volume: number) { return this.setSetting({ volume }); }
diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx
index d81455c..8f81bcf 100644
--- a/client/src/pages/EQPage.tsx
+++ b/client/src/pages/EQPage.tsx
@@ -183,6 +183,8 @@ export default function EQPage() {
const bandSliderDragRef = useRef(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(null);
+ const pendingPeqSyncRef = useRef<{
+ name: string;
+ persist: boolean;
+ run: () => Promise;
+ } | null>(null);
+ const inFlightPeqSyncRef = useRef>(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>({});
/** 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(null);
const filterMenuRef = useRef(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;
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 => {
+ 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);
}
};
diff --git a/client/src/pages/eq/components/BatchEditDialog.tsx b/client/src/pages/eq/components/BatchEditDialog.tsx
index 8bd18af..901c514 100644
--- a/client/src/pages/eq/components/BatchEditDialog.tsx
+++ b/client/src/pages/eq/components/BatchEditDialog.tsx
@@ -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}
>
@@ -43,6 +46,7 @@ export function BatchEditDialog({