优化 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]);
+33 -5
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 request = async () => {
const query = Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join("&");
await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
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> {
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,52 +381,64 @@ export class LuxsinAPI {
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
return this.enqueuePeqRequest(async () => {
const encoded = encodeCustomBase64(JSON.stringify(body));
const form = new URLSearchParams();
form.set("json", encoded);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
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> {
return this.enqueuePeqRequest(async () => {
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
const form = new URLSearchParams();
form.set("json", encoded);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
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> {
return this.enqueuePeqRequest(async () => {
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`, {
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> {
return this.enqueuePeqRequest(async () => {
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`, {
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}`);
});
}
setVolume(volume: number) { return this.setSetting({ volume }); }
+242 -232
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,
const patch = {
filters: filtersSource.map(bandToPeqFilter),
...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}),
...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}),
};
rememberPeqPresetCache(item.name, {
filters,
preamp: merged.preamp,
autoPre: merged.autoPre,
});
return merged;
}),
);
// 在事件中记录快照;不依赖 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;
}
selectHeadphone(idx, peqItems[idx]?.name);
updateSetting({ peqSelect: idx });
const target = peqItems[idx];
if (!target) return;
try {
await flushPendingPeqSync();
await updateSetting({ peqSelect: idx });
selectHeadphone(idx, target.name);
setIsHeadphoneMenuOpen(false);
} catch {
toast.error("EQ 保存失败");
}
},
[
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,9 +963,6 @@ export default function EQPage() {
let cancelled = false;
async function loadHeadphones() {
peqHydrationPendingRef.current = true;
allowPeqRemoteSyncRef.current = false;
try {
if (isDemoMode || !api) {
if (cancelled) return;
// 演示模式使用默认列表
@@ -1026,14 +1033,6 @@ export default function EQPage() {
selectHeadphone(0, "");
setCurrentRawCurve(null);
}
} finally {
if (cancelled) return;
allowPeqRemoteSyncRef.current = true;
skipPeqAutoSyncRef.current = true;
queueMicrotask(() => {
if (!cancelled) peqHydrationPendingRef.current = false;
});
}
}
void loadHeadphones();
return () => {
@@ -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,7 +1100,8 @@ export default function EQPage() {
const schedulePeqSync = useCallback(
(
peq: {
peq:
| {
name: string;
autoPre?: number;
preamp?: number;
@@ -1112,71 +1110,114 @@ export default function EQPage() {
model?: string;
target?: string;
form?: string;
} | undefined,
}
| 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 item = activePeqRef.current;
if (!item) return;
const merged = { ...item, ...patch };
updatedPeq = merged;
rememberPeqPresetCache(item.name, patch);
return merged;
});
return next;
});
schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx);
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)
+165
View File
@@ -0,0 +1,165 @@
// 运行:node --test scripts/eq-save-regression.test.mjs
// 无硬件测试:执行实际 API 和从 EQPage 提取的提交函数,不复制待测实现。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import ts from 'typescript';
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
const pageText = readFileSync(new URL('../client/src/pages/EQPage.tsx', import.meta.url), 'utf8');
const transpile = text => ts.transpileModule(text, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText;
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../client/src/lib/luxsinApi.ts', import.meta.url))],
bundle: true, write: false, format: 'esm', platform: 'node',
define: { 'import.meta.env.DEV': 'false' },
alias: { '@': fileURLToPath(new URL('../client/src', import.meta.url)) },
});
const { LuxsinAPI, encodeCustomBase64, decodeCustomBase64 } = await import(
'data:text/javascript;base64,' + Buffer.from(bundle.outputFiles[0].text).toString('base64')
);
const band = gain => ({ freq: 100, gain, q: 1, type: 0, enabled: true });
const preset = gain => ({ name: 'Copy', preamp: -2, autoPre: 0, canDel: 1,
filters: [{ fc: 100, gain, q: 1, type: 0 }] });
const flushMicrotasks = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
function scheduler() {
const source = ts.createSourceFile('EQPage.tsx', pageText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const component = source.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'EQPage');
const names = ['peqSyncTimerRef', 'pendingPeqSyncRef', 'inFlightPeqSyncRef', 'peqPresetCacheRef',
'cancelPendingPeqSync', 'flushPendingPeqSync', 'rememberPeqPresetCache',
'persistPresetEditsToPeqItem', 'schedulePeqSync'];
const definitions = names.map(name => {
const node = component.body.statements.find(n => ts.isVariableStatement(n)
&& n.declarationList.declarations.some(d => ts.isIdentifier(d.name) && d.name.text === name));
assert.ok(node, `缺少待测定义 ${name}`);
return node.getText(source);
}).join('\n');
const timers = new Map();
let sequence = 0;
const requests = [];
const errors = [];
const window = {
setTimeout: fn => { const id = ++sequence; timers.set(id, fn); return id; },
clearTimeout: id => timers.delete(id),
};
const setup = `
const useRef = current => ({current});
const useCallback = fn => fn;
const api = {}, isDemoMode = false;
let peqItems = [${JSON.stringify(preset(0))}], updates = [];
const setPeqItems = updater => updates.push(updater);
const bandToPeqFilter = b => ({fc: b.freq, gain: b.gain, q: b.q, type: b.type});
const buildPeqPresetBody = (peq, filters) => ({...peq, filters});
const upgradePeqChange = async payload => { requests.push(payload); };
const upgradePeqApply = async payload => { requests.push(payload); };
const toast = {error: message => errors.push(message)};
${definitions}
return {
schedule: schedulePeqSync, flush: flushPendingPeqSync,
cache: peqPresetCacheRef.current,
render: () => { for (const update of updates) peqItems = update(peqItems); updates = []; return peqItems; }
};`;
return { ...new Function('window', 'requests', 'errors', transpile(setup))(window, requests, errors), timers, requests, errors };
}
test('A 显式保存立即发送,不依赖 React updater 求值或定时器', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(3)], 'A', 0);
assert.equal(eq.requests.length, 1);
assert.equal(eq.requests[0].peqChange.filters[0].gain, 3);
assert.equal(eq.timers.size, 0);
assert.equal(eq.cache.Copy.filters[0].gain, 3);
assert.equal(eq.render()[0].filters[0].gain, 3);
});
test('单次编辑在防抖窗口内切换/卸载时补发,且不重复发送', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(4)], 'A');
assert.equal(eq.requests.length, 0);
await eq.flush();
await eq.flush();
assert.equal(eq.requests.length, 1);
assert.equal(eq.requests[0].peqChange.filters[0].gain, 4);
assert.equal(eq.timers.size, 0);
});
test('连续编辑合并为最新快照;元数据也进入载荷', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(1)], 'A');
await eq.schedule({ ...preset(0), preamp: -7, autoPre: 1 }, [band(2)], 'A');
assert.equal(eq.timers.size, 1);
await eq.flush();
assert.equal(eq.requests.length, 1);
assert.deepEqual([eq.requests[0].peqChange.filters[0].gain, eq.requests[0].peqChange.preamp, eq.requests[0].peqChange.autoPre], [2, -7, 1]);
});
test('B 试听不能替换尚未发送的 A 保存', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(1)], 'A');
await eq.schedule({ ...preset(0), preamp: -8 }, [band(5)], 'B', 0);
assert.deepEqual(eq.requests.map(p => Object.keys(p)[0]), ['peqChange', 'peqApply']);
assert.equal(eq.cache.Copy.filters[0].gain, 1);
assert.equal(eq.cache.Copy.preamp, -2);
assert.equal(eq.requests[1].peqApply.preamp, -8);
});
test('B 的待发试听在离开时取消,不写入预设缓存', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(5)], 'B');
await eq.flush();
assert.equal(eq.requests.length, 0);
assert.equal(eq.timers.size, 0);
assert.deepEqual(eq.cache, {});
});
test('切换调度目标时先提交原预设,而不是覆盖它的保存', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(3)], 'A');
await eq.schedule({ ...preset(0), name: 'Other' }, [band(4)], 'A', 0);
assert.deepEqual(eq.requests.map(p => p.peqChange.name), ['Copy', 'Other']);
});
test('真实 API 串行执行保存、试听、选择、目录操作及读取', async t => {
const started = [], release = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
assert.equal(new URL(url).hostname, 'eq-test.invalid');
const payload = options?.body ? JSON.parse(decodeCustomBase64(new URLSearchParams(options.body).get('json'))) : null;
started.push(payload ? Object.keys(payload)[0] : new URL(url).searchParams.get('action'));
await new Promise(resolve => release.push(resolve));
return new Response(encodeCustomBase64(JSON.stringify({ peq: [], filters: [] })));
});
const api = new LuxsinAPI('eq-test.invalid');
const calls = [api.upgradePeqChange({ peqChange: preset(1) }), api.upgradePeqApply({ peqApply: preset(2) }),
api.setPeqSelect(1), api.renamePeq('Copy', 'Renamed'), api.sortPeq(['Renamed'], 0), api.removePeq(['Renamed']), api.getPeqState()];
await flushMicrotasks();
assert.deepEqual(started, ['peqChange']);
for (let i = 0; i < calls.length; i++) {
assert.equal(started.length, i + 1, '前一个响应完成之前不应启动下一个请求');
release[i]();
await flushMicrotasks();
}
await Promise.all(calls);
assert.deepEqual(started, ['peqChange', 'peqApply', 'setting', 'peqRename', 'peqSoft', 'peqRemove', 'syncPeq']);
});
test('HTTP 500 必须失败,后续重试不被失败队列阻塞', async t => {
let count = 0;
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: ++count === 1 ? 500 : 200 }));
const api = new LuxsinAPI('eq-test.invalid');
await assert.rejects(api.upgradePeqChange({ peqChange: preset(1) }), /500/);
await api.upgradePeqChange({ peqChange: preset(2) });
assert.equal(count, 2);
});
test('保存失败不会变成静默成功;网络异常之后可重试', async t => {
let count = 0;
t.mock.method(globalThis, 'fetch', async () => {
if (++count === 1) throw new TypeError('mock network failure');
return new Response('');
});
const api = new LuxsinAPI('eq-test.invalid');
await assert.rejects(api.upgradePeqChange({ peqChange: preset(1) }), /network/);
await api.upgradePeqChange({ peqChange: preset(2) });
});