优化 peq 流程

This commit is contained in:
eafonyang
2026-09-20 13:58:32 +08:00
parent b606e7ad4d
commit cc818fe26e
6 changed files with 556 additions and 343 deletions
+2 -2
View File
@@ -196,7 +196,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]);
@@ -207,7 +207,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]);
+66 -38
View File
@@ -292,6 +292,14 @@ function normalizeDeviceHost(input: string): string {
// ============================================================
export class LuxsinAPI {
private baseUrl: string;
private peqRequestQueue: Promise<void> = Promise.resolve();
/** EQ 写入、试听、选择和目录读取按调用顺序执行;单次失败不阻塞后续重试。 */
private enqueuePeqRequest<T>(request: () => Promise<T>): Promise<T> {
const result = this.peqRequestQueue.then(request);
this.peqRequestQueue = result.then(() => undefined, () => undefined);
return result;
}
constructor(ip: string) {
const host = normalizeDeviceHost(ip);
@@ -336,17 +344,25 @@ export class LuxsinAPI {
}
async setSetting(params: Record<string, string | number>): Promise<void> {
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<PeqState> {
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<void> {
@@ -365,51 +381,63 @@ export class LuxsinAPI {
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
const encoded = encodeCustomBase64(JSON.stringify(body));
const form = new URLSearchParams();
form.set("json", encoded);
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(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}`);
});
}
/** Remove one or more headphone PEQ profiles. */
async removePeq(names: string[]): Promise<void> {
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
const form = new URLSearchParams();
form.set("json", encoded);
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 request failed: ${response.status}`);
});
}
/** Rename a headphone PEQ profile (firmware build 29+). */
async renamePeq(oldName: string, newName: string): Promise<void> {
const payload: PeqRenamePayload = { peqRename: { oldName, newName } };
const encoded = encodeCustomBase64(JSON.stringify(payload));
const form = new URLSearchParams();
form.set("json", encoded);
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 request failed: ${response.status}`);
});
}
/** Reorder headphone PEQ profiles (firmware build 29+). */
async sortPeq(names: string[], peqSelect: number): Promise<void> {
const payload: PeqSoftPayload = { peqSoft: names, peqSelect };
const encoded = encodeCustomBase64(JSON.stringify(payload));
const form = new URLSearchParams();
form.set("json", encoded);
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 request failed: ${response.status}`);
});
}
+313 -303
View File
@@ -165,6 +165,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);
@@ -194,40 +196,57 @@ 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 skipPeqAutoSyncRef = useRef(false);
/** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */
const skipBandsSyncFromAbToggleRef = useRef(false);
/** 本地编辑过的预设(filters / preamp / autoPre);syncPeq 拉取不会覆盖 */
const peqPresetCacheRef = useRef<Record<string, PeqPresetLocalCache>>({});
/** 最近一次防抖调度但尚未发送的同步请求;persist=true 表示 peqChange(需可靠送达,可被 flush 补发) */
const pendingPeqSyncRef = useRef<{
name: string;
persist: boolean;
run: () => Promise<void>;
} | null>(null);
const inFlightPeqSyncRef = useRef<Promise<void>>(Promise.resolve());
/** Anchor UI selection by preset name so poll/catalog reorder won't mis-switch headphones. */
const selectedPeqNameRef = useRef("");
/** 取消未发送的防抖同步(内容已被更新的操作整体取代时使用) */
const cancelPendingPeqSync = useCallback(() => {
pendingPeqSyncRef.current = null;
if (peqSyncTimerRef.current !== null) {
window.clearTimeout(peqSyncTimerRef.current);
peqSyncTimerRef.current = null;
}
}, []);
/** 立即补发未发送的 peqChange(持久化);peqApply 类试听同步直接丢弃,避免切换后错写新预设 */
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 devicePeqSelectRef = useRef(deviceState?.peqSelect);
devicePeqSelectRef.current = deviceState?.peqSelect;
const headphoneMenuRef = useRef<HTMLDivElement | null>(null);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
const bands = bandsByMode[abMode];
const selectedBand = selectedBandByMode[abMode];
const bandsByModeRef = useRef(bandsByMode);
bandsByModeRef.current = bandsByMode;
const [bPeqMeta, setBPeqMeta] = useState({ preamp: 0, autoPre: 0 });
const bPeqMetaRef = useRef(bPeqMeta);
bPeqMetaRef.current = bPeqMeta;
const setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => {
const cloned = cloneBands(nextBands);
setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) });
const next = { A: cloneBands(nextBands), B: cloneBands(nextBands) };
bandsByModeRef.current = next;
setBandsByMode(next);
}, []);
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 setSelectedBand = useCallback(
(updater: number | ((prev: number) => number)) => {
setSelectedBandByMode((prev) => ({
@@ -238,28 +257,34 @@ export default function EQPage() {
[abMode],
);
const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => {
setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(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 currentPeqName = currentPeq?.name ?? "";
const currentPeqPreamp = currentPeq?.preamp ?? 0;
const currentPeqAutoPre = currentPeq?.autoPre ?? 0;
const preampValue = Number(currentPeqPreamp);
const autoPreOn = currentPeqAutoPre === 1;
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 => {
@@ -272,6 +297,7 @@ export default function EQPage() {
},
preamp: src.preamp,
autoPre: src.autoPre,
metaByMode: { A: { ...src.metaByMode.A }, B: { ...src.metaByMode.B } },
};
}, []);
@@ -341,7 +367,7 @@ export default function EQPage() {
const toCompactNumber = (value: number) => 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++) {
@@ -351,7 +377,7 @@ export default function EQPage() {
);
}
return lines.join("\n");
}, [bands, currentPeq?.preamp]);
}, [bands, preampValue]);
const openBatchEditDialog = () => {
setBatchEditText(buildBatchEditText());
@@ -437,33 +463,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);
setIsBatchEditDialogOpen(false);
toast.success(eqUi.toastBatchApplied);
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 {
toast.error("EQ 保存失败");
} finally {
batchSavingRef.current = false;
setBatchSaving(false);
}
};
@@ -530,66 +561,40 @@ export default function EQPage() {
/** 将当前 A 曲线与总增益写回 peqItems 与本地缓存,切换耳机时才能恢复编辑结果 */
const persistPresetEditsToPeqItem = useCallback(
(
catalogIdx: number,
name: string,
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;
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],
);
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 applyPeqStateToUI = (
@@ -608,7 +613,10 @@ export default function EQPage() {
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 });
@@ -636,6 +644,7 @@ export default function EQPage() {
setDeleteHeadphoneDialog(null);
try {
await flushPendingPeqSync();
if (isDemoMode || !api) {
const nextItems = peqItems.filter((_, idx) => idx !== target.idx);
delete peqPresetCacheRef.current[target.name];
@@ -701,8 +710,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),
@@ -717,6 +726,7 @@ export default function EQPage() {
};
try {
await flushPendingPeqSync();
await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "addPreset");
@@ -730,8 +740,8 @@ export default function EQPage() {
const localFilters = addPresetMode === "copy"
? bands.map(bandToPeqFilter)
: FLAT_PRESET_FILTERS;
const localAutoPre = addPresetMode === "copy" ? (currentPeq?.autoPre ?? 0) : 0;
const localPreamp = addPresetMode === "copy" ? (currentPeq?.preamp ?? 0) : 0;
const localAutoPre = addPresetMode === "copy" ? (activePeq?.autoPre ?? 0) : 0;
const localPreamp = addPresetMode === "copy" ? (activePeq?.preamp ?? 0) : 0;
setHeadphoneModels((prev) => [...prev, nextName]);
setPeqItems((prev) => [
...prev,
@@ -781,8 +791,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,
@@ -790,6 +800,7 @@ export default function EQPage() {
};
try {
await flushPendingPeqSync();
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "saveBAsPreset");
@@ -806,8 +817,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,
},
]);
@@ -873,8 +884,8 @@ export default function EQPage() {
),
};
skipPeqAutoSyncRef.current = true;
try {
await flushPendingPeqSync();
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "importSharedEq");
@@ -925,7 +936,6 @@ export default function EQPage() {
toast.success(eqUi.importSuccess);
setIsShareDialogOpen(false);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail);
}
};
@@ -953,86 +963,75 @@ 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);
// 更新 bands 状态
if (fixedFilters && fixedFilters.length > 0) {
setBandsForBothModes(fixedFilters);
setSelectedBandByMode({ A: 0, B: 0 });
}
// raw 与图表渲染统一交给 headphoneIdx/peqItems 监听逻辑处理,
// 避免首次进入页面时重复请求 modelCurve。
// 初始化当前选中的耳机曲线
const currentPeq = mergedPeq[nextIdx];
if (currentPeq) {
// 修复 filters 数据(兼容不同的字段名)
const fixedFilters = normalizeFiltersFromPeq(currentPeq);
// 更新 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();
@@ -1062,6 +1061,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);
@@ -1072,8 +1074,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());
@@ -1091,9 +1091,6 @@ export default function EQPage() {
setCurrentRawCurve(raw);
setRawCurveLoading(false);
})();
requestAnimationFrame(() => {
syncingHeadphoneRef.current = false;
});
return () => {
cancelled = true;
};
@@ -1103,80 +1100,124 @@ export default function EQPage() {
const schedulePeqSync = useCallback(
(
peq: {
name: string;
autoPre?: number;
preamp?: number;
canDel?: number;
brand?: string;
model?: string;
target?: string;
form?: string;
} | undefined,
peq:
| {
name: string;
autoPre?: number;
preamp?: number;
canDel?: number;
brand?: string;
model?: string;
target?: string;
form?: string;
}
| undefined,
filtersSource: typeof DEFAULT_BANDS,
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, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange],
[
api,
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(A/B 数据结构相同,仅 filters 不同) */
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,
@@ -1184,26 +1225,6 @@ export default function EQPage() {
],
);
// 参数编辑防抖:A→peqChange,B→peqApply(不因切换 A/B 误触发)
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, currentPeqName, currentPeqPreamp, currentPeqAutoPre, schedulePeqSync]);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
@@ -1227,7 +1248,8 @@ export default function EQPage() {
}, [selectedBand, abMode, headphoneSwitchKey]);
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;
@@ -1253,29 +1275,15 @@ export default function EQPage() {
),
};
skipPeqAutoSyncRef.current = true;
setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) }));
const next = { ...bandsByModeRef.current, A: cloneBands(bBands) };
bandsByModeRef.current = next;
setBandsByMode(next);
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 })),
);
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);
}
}, [
@@ -1285,9 +1293,8 @@ export default function EQPage() {
eqUi.toastApplyBSuccess,
headphoneIdx,
peqItems,
rememberPeqPresetCache,
schedulePeqSync,
takeUndoSnapshot,
upgradePeqChange,
]);
const updateBand = useCallback(
@@ -1412,18 +1419,17 @@ export default function EQPage() {
};
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 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 = () => {
@@ -1444,29 +1450,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);
};
@@ -1880,7 +1886,8 @@ export default function EQPage() {
text={batchEditText}
onChangeText={setBatchEditText}
onSave={handleSaveBatchEdit}
onClose={() => setIsBatchEditDialogOpen(false)}
saving={batchSaving}
onClose={() => { if (!batchSavingRef.current) setIsBatchEditDialogOpen(false); }}
eqUi={eqUi}
/>
@@ -1939,8 +1946,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");
@@ -1994,7 +2001,6 @@ export default function EQPage() {
}
setIsBrandDrawerOpen(false);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.toastAddPresetFail);
}
};
@@ -2050,6 +2056,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 => delete peqPresetCacheRef.current[n]);
@@ -2070,6 +2077,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;
}
@@ -2129,6 +2138,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
@@ -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)