新增 EQ撤销功能,用户调节的参数不满意可以点击回退到上一次,撤销栈最多存放 30 步
This commit is contained in:
+144
-9
@@ -44,7 +44,13 @@ import { ShareDialog } from "./eq/components/ShareDialog";
|
||||
import { BrandDrawer } from "./eq/components/BrandDrawer";
|
||||
import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog";
|
||||
import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog";
|
||||
import { PeqUndoButton } from "./eq/components/PeqUndoButton";
|
||||
import { useRawCurve } from "./eq/hooks/useRawCurve";
|
||||
import {
|
||||
usePeqUndoHistory,
|
||||
type PeqUndoSnapshot,
|
||||
} from "./eq/hooks/usePeqUndoHistory";
|
||||
import { formatPeqUndoToast } from "./eq/peqUndoDiff";
|
||||
import { isPeqPresetNameTooLong } from "./eq/peqPresetName";
|
||||
import {
|
||||
BAND_FREQ_MAX,
|
||||
@@ -180,6 +186,14 @@ export default function EQPage() {
|
||||
setRawCurveLoading,
|
||||
loadRawCurveForPeq,
|
||||
} = useRawCurve();
|
||||
const {
|
||||
canUndo,
|
||||
clear: clearPeqUndo,
|
||||
captureBefore,
|
||||
beginGesture,
|
||||
commitGesture,
|
||||
undo: popPeqUndo,
|
||||
} = usePeqUndoHistory();
|
||||
const allowPeqRemoteSyncRef = useRef(false);
|
||||
const peqHydrationPendingRef = useRef(true);
|
||||
const lastPeqCatalogSyncKeyRef = useRef("");
|
||||
@@ -235,6 +249,32 @@ export default function EQPage() {
|
||||
const preampValue = Number(currentPeqPreamp);
|
||||
const autoPreOn = currentPeqAutoPre === 1;
|
||||
|
||||
const undoSnapshotSourceRef = useRef({
|
||||
abMode,
|
||||
bandsByMode,
|
||||
preamp: preampValue,
|
||||
autoPre: autoPreOn ? 1 : 0,
|
||||
});
|
||||
undoSnapshotSourceRef.current = {
|
||||
abMode,
|
||||
bandsByMode,
|
||||
preamp: preampValue,
|
||||
autoPre: autoPreOn ? 1 : 0,
|
||||
};
|
||||
|
||||
const takeUndoSnapshot = useCallback((): PeqUndoSnapshot => {
|
||||
const src = undoSnapshotSourceRef.current;
|
||||
return {
|
||||
abMode: src.abMode,
|
||||
bandsByMode: {
|
||||
A: cloneBands(src.bandsByMode.A),
|
||||
B: cloneBands(src.bandsByMode.B),
|
||||
},
|
||||
preamp: src.preamp,
|
||||
autoPre: src.autoPre,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const guardPeqPresetCapacity = () => {
|
||||
if (peqItems.length < PEQ_PRESET_MAX) return true;
|
||||
toast.error(
|
||||
@@ -400,6 +440,7 @@ export default function EQPage() {
|
||||
const handleSaveBatchEdit = () => {
|
||||
try {
|
||||
const parsed = parseBatchEditText(batchEditText);
|
||||
captureBefore(takeUndoSnapshot());
|
||||
let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined;
|
||||
setPeqItems((prev) =>
|
||||
prev.map((item, i) => {
|
||||
@@ -1021,6 +1062,17 @@ export default function EQPage() {
|
||||
return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001");
|
||||
}, [headphoneIdx, peqItems]);
|
||||
|
||||
// 切换预设或离开页面时清空撤销栈
|
||||
useEffect(() => {
|
||||
clearPeqUndo();
|
||||
}, [headphoneSwitchKey, clearPeqUndo]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPeqUndo();
|
||||
};
|
||||
}, [clearPeqUndo]);
|
||||
|
||||
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
|
||||
useEffect(() => {
|
||||
const peq = peqItems[headphoneIdx];
|
||||
@@ -1028,6 +1080,7 @@ 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());
|
||||
@@ -1119,13 +1172,23 @@ export default function EQPage() {
|
||||
const handleCopyAndSwitchTo = useCallback(
|
||||
(to: "A" | "B") => {
|
||||
const from = abMode;
|
||||
captureBefore(takeUndoSnapshot());
|
||||
const copied = cloneBands(bandsByMode[from]);
|
||||
copyModeParams(from, to);
|
||||
skipBandsSyncFromAbToggleRef.current = true;
|
||||
setAbMode(to);
|
||||
schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply");
|
||||
},
|
||||
[abMode, bandsByMode, copyModeParams, headphoneIdx, peqItems, schedulePeqSync],
|
||||
[
|
||||
abMode,
|
||||
bandsByMode,
|
||||
captureBefore,
|
||||
copyModeParams,
|
||||
headphoneIdx,
|
||||
peqItems,
|
||||
schedulePeqSync,
|
||||
takeUndoSnapshot,
|
||||
],
|
||||
);
|
||||
|
||||
// 参数编辑防抖:A→peqChange,B→peqApply(不因切换 A/B 误触发)
|
||||
@@ -1177,6 +1240,8 @@ export default function EQPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
captureBefore(takeUndoSnapshot());
|
||||
|
||||
const bBands = cloneBands(bandsByMode.B);
|
||||
const filters = bBands.map(bandToPeqFilter);
|
||||
const payload: PeqChangePayload = {
|
||||
@@ -1222,10 +1287,13 @@ export default function EQPage() {
|
||||
}
|
||||
}, [
|
||||
bandsByMode.B,
|
||||
captureBefore,
|
||||
eqUi.toastApplyBFail,
|
||||
eqUi.toastApplyBSuccess,
|
||||
headphoneIdx,
|
||||
peqItems,
|
||||
rememberPeqPresetCache,
|
||||
takeUndoSnapshot,
|
||||
upgradePeqChange,
|
||||
]);
|
||||
|
||||
@@ -1245,17 +1313,22 @@ export default function EQPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const beginBandSliderDrag = useCallback((kind: BandParamKind) => {
|
||||
bandSliderDragRef.current = kind;
|
||||
}, []);
|
||||
const beginBandSliderDrag = useCallback(
|
||||
(kind: BandParamKind) => {
|
||||
bandSliderDragRef.current = kind;
|
||||
beginGesture(takeUndoSnapshot());
|
||||
},
|
||||
[beginGesture, takeUndoSnapshot],
|
||||
);
|
||||
|
||||
const endBandSliderDrag = useCallback(
|
||||
(kind: BandParamKind) => {
|
||||
if (bandSliderDragRef.current !== kind) return;
|
||||
bandSliderDragRef.current = null;
|
||||
clearBandSliderPreviewField(kind);
|
||||
commitGesture(takeUndoSnapshot());
|
||||
},
|
||||
[clearBandSliderPreviewField],
|
||||
[clearBandSliderPreviewField, commitGesture, takeUndoSnapshot],
|
||||
);
|
||||
|
||||
const handleBandSliderChange = useCallback(
|
||||
@@ -1336,6 +1409,7 @@ export default function EQPage() {
|
||||
toast.error(parsed.message);
|
||||
return;
|
||||
}
|
||||
captureBefore(takeUndoSnapshot());
|
||||
bandSliderDragRef.current = null;
|
||||
if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value });
|
||||
else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value });
|
||||
@@ -1359,6 +1433,50 @@ export default function EQPage() {
|
||||
schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx);
|
||||
};
|
||||
|
||||
const beginPreampSliderDrag = () => {
|
||||
beginGesture(takeUndoSnapshot());
|
||||
};
|
||||
|
||||
const endPreampSliderDrag = () => {
|
||||
commitGesture(takeUndoSnapshot());
|
||||
};
|
||||
|
||||
const handleAutoPreChange = (v: boolean) => {
|
||||
captureBefore(takeUndoSnapshot());
|
||||
updateCurrentPeqMeta({ autoPre: v ? 1 : 0 });
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
const current = takeUndoSnapshot();
|
||||
const snap = popPeqUndo();
|
||||
if (!snap) return;
|
||||
const toastMsg = formatPeqUndoToast(current, snap, eqUi);
|
||||
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;
|
||||
}),
|
||||
);
|
||||
toast.success(toastMsg);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
<div style={{
|
||||
@@ -1434,10 +1552,13 @@ export default function EQPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Headphone model selector ── */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex min-w-0 items-center gap-2 px-1">
|
||||
<div
|
||||
ref={headphoneMenuRef}
|
||||
className="relative z-40 flex-1 mr-2 rounded-[12px] overflow-visible backdrop-blur-md transition-all"
|
||||
className={cn(
|
||||
"relative min-w-0 flex-1 max-w-[calc(100%-9.5rem)] rounded-[12px] overflow-visible backdrop-blur-md transition-all",
|
||||
isHeadphoneMenuOpen && "z-30",
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
|
||||
border: "1px solid rgba(0,255,246,0.52)",
|
||||
@@ -1485,7 +1606,12 @@ export default function EQPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<PeqUndoButton
|
||||
disabled={!canUndo}
|
||||
label={eqUi.undo ?? "Undo"}
|
||||
onClick={handleUndo}
|
||||
/>
|
||||
<button
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
|
||||
@@ -1524,6 +1650,8 @@ export default function EQPage() {
|
||||
onApplyB={() => void handleApplyB()}
|
||||
onSaveB={openSaveBDialog}
|
||||
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
|
||||
onBandDragStart={() => beginGesture(takeUndoSnapshot())}
|
||||
onBandDragEnd={() => commitGesture(takeUndoSnapshot())}
|
||||
onBandSelect={setSelectedBand}
|
||||
eqUi={eqUi}
|
||||
/>
|
||||
@@ -1601,6 +1729,11 @@ export default function EQPage() {
|
||||
}`}
|
||||
style={active ? { background: "#00FFF6" } : undefined}
|
||||
onClick={() => {
|
||||
if (normalizeFilterType(band.type) === type) {
|
||||
setIsFilterMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
captureBefore(takeUndoSnapshot());
|
||||
updateBand(selectedBand, { type });
|
||||
setIsFilterMenuOpen(false);
|
||||
}}
|
||||
@@ -1702,7 +1835,7 @@ export default function EQPage() {
|
||||
<span className="text-[12px] text-white/40 tracking-wider">AUTO</span>
|
||||
<IOSToggle
|
||||
checked={autoPreOn}
|
||||
onChange={(v) => updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })}
|
||||
onChange={handleAutoPreChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1712,6 +1845,8 @@ export default function EQPage() {
|
||||
max={30}
|
||||
step={0.1}
|
||||
disabled={autoPreOn}
|
||||
onInteractionStart={beginPreampSliderDrag}
|
||||
onInteractionEnd={endPreampSliderDrag}
|
||||
onChange={(v) => updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user