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