优化 peq 流程
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
Reference in New Issue
Block a user