新增 EQ撤销功能,用户调节的参数不满意可以点击回退到上一次,撤销栈最多存放 30 步
This commit is contained in:
@@ -270,6 +270,23 @@
|
|||||||
"closeDrawer": "Schließen",
|
"closeDrawer": "Schließen",
|
||||||
"toastBatchApplied": "Stapelbearbeitung angewendet",
|
"toastBatchApplied": "Stapelbearbeitung angewendet",
|
||||||
"toastBatchParseError": "Ungültiges Stapelbearbeitungsformat",
|
"toastBatchParseError": "Ungültiges Stapelbearbeitungsformat",
|
||||||
|
"undo": "Rückgängig",
|
||||||
|
"toastUndo": "Rückgängig: {{detail}}",
|
||||||
|
"toastUndoEmpty": "Rückgängig gemacht",
|
||||||
|
"undoBandParam": "{{mode}} Band {{band}} {{param}} {{from}} → {{to}}",
|
||||||
|
"undoSimpleParam": "{{param}} {{from}} → {{to}}",
|
||||||
|
"undoMultiple": "{{count}} Änderungen ({{summary}})",
|
||||||
|
"undoMoreSuffix": " (+{{count}} weitere)",
|
||||||
|
"undoDetailSep": "; ",
|
||||||
|
"undoParamFreq": "FREQ",
|
||||||
|
"undoParamGain": "GAIN",
|
||||||
|
"undoParamQ": "Q",
|
||||||
|
"undoParamType": "Filter",
|
||||||
|
"undoParamPreamp": "Preamp",
|
||||||
|
"undoParamAuto": "AUTO",
|
||||||
|
"undoParamAbMode": "A/B",
|
||||||
|
"undoAutoOn": "EIN",
|
||||||
|
"undoAutoOff": "AUS",
|
||||||
"toastBrandsFail": "Laden der Marken fehlgeschlagen",
|
"toastBrandsFail": "Laden der Marken fehlgeschlagen",
|
||||||
"toastModelsFail": "Laden der Modelle fehlgeschlagen",
|
"toastModelsFail": "Laden der Modelle fehlgeschlagen",
|
||||||
"toastCurveFail": "Laden der Kurve fehlgeschlagen",
|
"toastCurveFail": "Laden der Kurve fehlgeschlagen",
|
||||||
|
|||||||
@@ -270,6 +270,23 @@
|
|||||||
"closeDrawer": "Close",
|
"closeDrawer": "Close",
|
||||||
"toastBatchApplied": "Batch edit applied",
|
"toastBatchApplied": "Batch edit applied",
|
||||||
"toastBatchParseError": "Invalid batch edit format",
|
"toastBatchParseError": "Invalid batch edit format",
|
||||||
|
"undo": "Undo",
|
||||||
|
"toastUndo": "Undone: {{detail}}",
|
||||||
|
"toastUndoEmpty": "Undone",
|
||||||
|
"undoBandParam": "{{mode}} Band {{band}} {{param}} {{from}} → {{to}}",
|
||||||
|
"undoSimpleParam": "{{param}} {{from}} → {{to}}",
|
||||||
|
"undoMultiple": "{{count}} changes ({{summary}})",
|
||||||
|
"undoMoreSuffix": " (+{{count}} more)",
|
||||||
|
"undoDetailSep": "; ",
|
||||||
|
"undoParamFreq": "FREQ",
|
||||||
|
"undoParamGain": "GAIN",
|
||||||
|
"undoParamQ": "Q",
|
||||||
|
"undoParamType": "Filter",
|
||||||
|
"undoParamPreamp": "Preamp",
|
||||||
|
"undoParamAuto": "AUTO",
|
||||||
|
"undoParamAbMode": "A/B",
|
||||||
|
"undoAutoOn": "ON",
|
||||||
|
"undoAutoOff": "OFF",
|
||||||
"toastBrandsFail": "Failed to load brands",
|
"toastBrandsFail": "Failed to load brands",
|
||||||
"toastModelsFail": "Failed to load models",
|
"toastModelsFail": "Failed to load models",
|
||||||
"toastCurveFail": "Failed to load curve",
|
"toastCurveFail": "Failed to load curve",
|
||||||
|
|||||||
@@ -270,6 +270,23 @@
|
|||||||
"closeDrawer": "Cerrar",
|
"closeDrawer": "Cerrar",
|
||||||
"toastBatchApplied": "Edición por lotes aplicada",
|
"toastBatchApplied": "Edición por lotes aplicada",
|
||||||
"toastBatchParseError": "Formato de edición por lotes inválido",
|
"toastBatchParseError": "Formato de edición por lotes inválido",
|
||||||
|
"undo": "Deshacer",
|
||||||
|
"toastUndo": "Deshecho: {{detail}}",
|
||||||
|
"toastUndoEmpty": "Deshecho",
|
||||||
|
"undoBandParam": "{{mode}} Banda {{band}} {{param}} {{from}} → {{to}}",
|
||||||
|
"undoSimpleParam": "{{param}} {{from}} → {{to}}",
|
||||||
|
"undoMultiple": "{{count}} cambios ({{summary}})",
|
||||||
|
"undoMoreSuffix": " (+{{count}} más)",
|
||||||
|
"undoDetailSep": "; ",
|
||||||
|
"undoParamFreq": "FREQ",
|
||||||
|
"undoParamGain": "GAIN",
|
||||||
|
"undoParamQ": "Q",
|
||||||
|
"undoParamType": "Filtro",
|
||||||
|
"undoParamPreamp": "Preamp",
|
||||||
|
"undoParamAuto": "AUTO",
|
||||||
|
"undoParamAbMode": "A/B",
|
||||||
|
"undoAutoOn": "ON",
|
||||||
|
"undoAutoOff": "OFF",
|
||||||
"toastBrandsFail": "Error al cargar las marcas",
|
"toastBrandsFail": "Error al cargar las marcas",
|
||||||
"toastModelsFail": "Error al cargar los modelos",
|
"toastModelsFail": "Error al cargar los modelos",
|
||||||
"toastCurveFail": "Error al cargar la curva",
|
"toastCurveFail": "Error al cargar la curva",
|
||||||
|
|||||||
@@ -270,6 +270,23 @@
|
|||||||
"closeDrawer": "Fermer",
|
"closeDrawer": "Fermer",
|
||||||
"toastBatchApplied": "Édition par lot appliquée",
|
"toastBatchApplied": "Édition par lot appliquée",
|
||||||
"toastBatchParseError": "Format d'édition par lot invalide",
|
"toastBatchParseError": "Format d'édition par lot invalide",
|
||||||
|
"undo": "Annuler",
|
||||||
|
"toastUndo": "Annulé : {{detail}}",
|
||||||
|
"toastUndoEmpty": "Annulé",
|
||||||
|
"undoBandParam": "{{mode}} Bande {{band}} {{param}} {{from}} → {{to}}",
|
||||||
|
"undoSimpleParam": "{{param}} {{from}} → {{to}}",
|
||||||
|
"undoMultiple": "{{count}} modifications ({{summary}})",
|
||||||
|
"undoMoreSuffix": " (+{{count}})",
|
||||||
|
"undoDetailSep": " ; ",
|
||||||
|
"undoParamFreq": "FREQ",
|
||||||
|
"undoParamGain": "GAIN",
|
||||||
|
"undoParamQ": "Q",
|
||||||
|
"undoParamType": "Filtre",
|
||||||
|
"undoParamPreamp": "Preamp",
|
||||||
|
"undoParamAuto": "AUTO",
|
||||||
|
"undoParamAbMode": "A/B",
|
||||||
|
"undoAutoOn": "ON",
|
||||||
|
"undoAutoOff": "OFF",
|
||||||
"toastBrandsFail": "Échec du chargement des marques",
|
"toastBrandsFail": "Échec du chargement des marques",
|
||||||
"toastModelsFail": "Échec du chargement des modèles",
|
"toastModelsFail": "Échec du chargement des modèles",
|
||||||
"toastCurveFail": "Échec du chargement de la courbe",
|
"toastCurveFail": "Échec du chargement de la courbe",
|
||||||
|
|||||||
@@ -263,6 +263,23 @@
|
|||||||
"closeDrawer": "關閉",
|
"closeDrawer": "關閉",
|
||||||
"toastBatchApplied": "批次編輯已套用",
|
"toastBatchApplied": "批次編輯已套用",
|
||||||
"toastBatchParseError": "批次編輯格式錯誤",
|
"toastBatchParseError": "批次編輯格式錯誤",
|
||||||
|
"undo": "復原",
|
||||||
|
"toastUndo": "已復原:{{detail}}",
|
||||||
|
"toastUndoEmpty": "已復原",
|
||||||
|
"undoBandParam": "{{mode}} 頻段{{band}} {{param}} {{from}} → {{to}}",
|
||||||
|
"undoSimpleParam": "{{param}} {{from}} → {{to}}",
|
||||||
|
"undoMultiple": "{{count}} 項變更({{summary}})",
|
||||||
|
"undoMoreSuffix": "(另有 {{count}} 項)",
|
||||||
|
"undoDetailSep": ";",
|
||||||
|
"undoParamFreq": "頻率",
|
||||||
|
"undoParamGain": "增益",
|
||||||
|
"undoParamQ": "Q",
|
||||||
|
"undoParamType": "濾波器",
|
||||||
|
"undoParamPreamp": "總增益",
|
||||||
|
"undoParamAuto": "AUTO",
|
||||||
|
"undoParamAbMode": "A/B",
|
||||||
|
"undoAutoOn": "開",
|
||||||
|
"undoAutoOff": "關",
|
||||||
"toastBrandsFail": "取得品牌列表失敗",
|
"toastBrandsFail": "取得品牌列表失敗",
|
||||||
"toastModelsFail": "取得型號列表失敗",
|
"toastModelsFail": "取得型號列表失敗",
|
||||||
"toastCurveFail": "取得曲線失敗",
|
"toastCurveFail": "取得曲線失敗",
|
||||||
|
|||||||
@@ -263,6 +263,23 @@
|
|||||||
"closeDrawer": "关闭",
|
"closeDrawer": "关闭",
|
||||||
"toastBatchApplied": "批量编辑已应用",
|
"toastBatchApplied": "批量编辑已应用",
|
||||||
"toastBatchParseError": "批量编辑格式错误",
|
"toastBatchParseError": "批量编辑格式错误",
|
||||||
|
"undo": "撤销",
|
||||||
|
"toastUndo": "已撤销:{{detail}}",
|
||||||
|
"toastUndoEmpty": "已撤销",
|
||||||
|
"undoBandParam": "{{mode}} 频段{{band}} {{param}} {{from}} → {{to}}",
|
||||||
|
"undoSimpleParam": "{{param}} {{from}} → {{to}}",
|
||||||
|
"undoMultiple": "{{count}} 项变更({{summary}})",
|
||||||
|
"undoMoreSuffix": "(另有 {{count}} 项)",
|
||||||
|
"undoDetailSep": ";",
|
||||||
|
"undoParamFreq": "频率",
|
||||||
|
"undoParamGain": "增益",
|
||||||
|
"undoParamQ": "Q",
|
||||||
|
"undoParamType": "滤波器",
|
||||||
|
"undoParamPreamp": "总增益",
|
||||||
|
"undoParamAuto": "AUTO",
|
||||||
|
"undoParamAbMode": "A/B",
|
||||||
|
"undoAutoOn": "开",
|
||||||
|
"undoAutoOff": "关",
|
||||||
"toastBrandsFail": "获取品牌列表失败",
|
"toastBrandsFail": "获取品牌列表失败",
|
||||||
"toastModelsFail": "获取型号列表失败",
|
"toastModelsFail": "获取型号列表失败",
|
||||||
"toastCurveFail": "获取曲线失败",
|
"toastCurveFail": "获取曲线失败",
|
||||||
|
|||||||
+133
-6
@@ -87,7 +87,13 @@ import { ShareDialog } from "./eq/components/ShareDialog";
|
|||||||
import { BrandDrawer } from "./eq/components/BrandDrawer";
|
import { BrandDrawer } from "./eq/components/BrandDrawer";
|
||||||
import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog";
|
import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog";
|
||||||
import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog";
|
import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog";
|
||||||
|
import { PeqUndoButton } from "./eq/components/PeqUndoButton";
|
||||||
import { useRawCurve } from "./eq/hooks/useRawCurve";
|
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 { isPeqPresetNameTooLong } from "./eq/peqPresetName";
|
||||||
|
|
||||||
/* ── Default bands imported from eq/constants ── */
|
/* ── Default bands imported from eq/constants ── */
|
||||||
@@ -199,6 +205,14 @@ export default function EQPage() {
|
|||||||
setRawCurveLoading,
|
setRawCurveLoading,
|
||||||
loadRawCurveForPeq,
|
loadRawCurveForPeq,
|
||||||
} = useRawCurve();
|
} = useRawCurve();
|
||||||
|
const {
|
||||||
|
canUndo,
|
||||||
|
clear: clearPeqUndo,
|
||||||
|
captureBefore,
|
||||||
|
beginGesture,
|
||||||
|
commitGesture,
|
||||||
|
undo: popPeqUndo,
|
||||||
|
} = usePeqUndoHistory();
|
||||||
const allowPeqRemoteSyncRef = useRef(false);
|
const allowPeqRemoteSyncRef = useRef(false);
|
||||||
const peqHydrationPendingRef = useRef(true);
|
const peqHydrationPendingRef = useRef(true);
|
||||||
const lastPeqCatalogSyncKeyRef = useRef("");
|
const lastPeqCatalogSyncKeyRef = useRef("");
|
||||||
@@ -261,6 +275,32 @@ export default function EQPage() {
|
|||||||
const preampValue = Number(currentPeq?.preamp ?? 0);
|
const preampValue = Number(currentPeq?.preamp ?? 0);
|
||||||
const autoPreOn = (currentPeq?.autoPre ?? 0) === 1;
|
const autoPreOn = (currentPeq?.autoPre ?? 0) === 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 = () => {
|
const guardPeqPresetCapacity = () => {
|
||||||
if (peqItems.length < PEQ_PRESET_MAX) return true;
|
if (peqItems.length < PEQ_PRESET_MAX) return true;
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -436,6 +476,7 @@ export default function EQPage() {
|
|||||||
const handleSaveBatchEdit = () => {
|
const handleSaveBatchEdit = () => {
|
||||||
try {
|
try {
|
||||||
const parsed = parseBatchEditText(batchEditText);
|
const parsed = parseBatchEditText(batchEditText);
|
||||||
|
captureBefore(takeUndoSnapshot());
|
||||||
let updatedPeq:
|
let updatedPeq:
|
||||||
| { name: string; autoPre?: number; preamp?: number; canDel?: number }
|
| { name: string; autoPre?: number; preamp?: number; canDel?: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -1062,6 +1103,17 @@ export default function EQPage() {
|
|||||||
].join("\u0001");
|
].join("\u0001");
|
||||||
}, [headphoneIdx, peqItems]);
|
}, [headphoneIdx, peqItems]);
|
||||||
|
|
||||||
|
// 切换预设或离开页面时清空撤销栈
|
||||||
|
useEffect(() => {
|
||||||
|
clearPeqUndo();
|
||||||
|
}, [headphoneSwitchKey, clearPeqUndo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearPeqUndo();
|
||||||
|
};
|
||||||
|
}, [clearPeqUndo]);
|
||||||
|
|
||||||
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
|
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const peq = peqItems[headphoneIdx];
|
const peq = peqItems[headphoneIdx];
|
||||||
@@ -1241,6 +1293,7 @@ export default function EQPage() {
|
|||||||
const handleCopyAndSwitchTo = useCallback(
|
const handleCopyAndSwitchTo = useCallback(
|
||||||
(to: "A" | "B") => {
|
(to: "A" | "B") => {
|
||||||
const from = abMode;
|
const from = abMode;
|
||||||
|
captureBefore(takeUndoSnapshot());
|
||||||
const copied = cloneBands(bandsByMode[from]);
|
const copied = cloneBands(bandsByMode[from]);
|
||||||
copyModeParams(from, to);
|
copyModeParams(from, to);
|
||||||
skipBandsSyncFromAbToggleRef.current = true;
|
skipBandsSyncFromAbToggleRef.current = true;
|
||||||
@@ -1250,10 +1303,12 @@ export default function EQPage() {
|
|||||||
[
|
[
|
||||||
abMode,
|
abMode,
|
||||||
bandsByMode,
|
bandsByMode,
|
||||||
|
captureBefore,
|
||||||
copyModeParams,
|
copyModeParams,
|
||||||
headphoneIdx,
|
headphoneIdx,
|
||||||
peqItems,
|
peqItems,
|
||||||
schedulePeqSync,
|
schedulePeqSync,
|
||||||
|
takeUndoSnapshot,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1310,6 +1365,8 @@ export default function EQPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureBefore(takeUndoSnapshot());
|
||||||
|
|
||||||
const bBands = cloneBands(bandsByMode.B);
|
const bBands = cloneBands(bandsByMode.B);
|
||||||
const filters = bBands.map(bandToPeqFilter);
|
const filters = bBands.map(bandToPeqFilter);
|
||||||
const payload: PeqChangePayload = {
|
const payload: PeqChangePayload = {
|
||||||
@@ -1355,11 +1412,13 @@ export default function EQPage() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
bandsByMode.B,
|
bandsByMode.B,
|
||||||
|
captureBefore,
|
||||||
eqUi.toastApplyBFail,
|
eqUi.toastApplyBFail,
|
||||||
eqUi.toastApplyBSuccess,
|
eqUi.toastApplyBSuccess,
|
||||||
headphoneIdx,
|
headphoneIdx,
|
||||||
peqItems,
|
peqItems,
|
||||||
rememberPeqPresetCache,
|
rememberPeqPresetCache,
|
||||||
|
takeUndoSnapshot,
|
||||||
upgradePeqChange,
|
upgradePeqChange,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1385,17 +1444,22 @@ export default function EQPage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const beginBandSliderDrag = useCallback((kind: BandParamKind) => {
|
const beginBandSliderDrag = useCallback(
|
||||||
bandSliderDragRef.current = kind;
|
(kind: BandParamKind) => {
|
||||||
}, []);
|
bandSliderDragRef.current = kind;
|
||||||
|
beginGesture(takeUndoSnapshot());
|
||||||
|
},
|
||||||
|
[beginGesture, takeUndoSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
const endBandSliderDrag = useCallback(
|
const endBandSliderDrag = useCallback(
|
||||||
(kind: BandParamKind) => {
|
(kind: BandParamKind) => {
|
||||||
if (bandSliderDragRef.current !== kind) return;
|
if (bandSliderDragRef.current !== kind) return;
|
||||||
bandSliderDragRef.current = null;
|
bandSliderDragRef.current = null;
|
||||||
clearBandSliderPreviewField(kind);
|
clearBandSliderPreviewField(kind);
|
||||||
|
commitGesture(takeUndoSnapshot());
|
||||||
},
|
},
|
||||||
[clearBandSliderPreviewField]
|
[clearBandSliderPreviewField, commitGesture, takeUndoSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBandSliderChange = useCallback(
|
const handleBandSliderChange = useCallback(
|
||||||
@@ -1485,6 +1549,7 @@ export default function EQPage() {
|
|||||||
toast.error(parsed.message);
|
toast.error(parsed.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
captureBefore(takeUndoSnapshot());
|
||||||
bandSliderDragRef.current = null;
|
bandSliderDragRef.current = null;
|
||||||
if (bandParamDialog === "freq")
|
if (bandParamDialog === "freq")
|
||||||
updateBand(selectedBand, { freq: parsed.value });
|
updateBand(selectedBand, { freq: parsed.value });
|
||||||
@@ -1514,6 +1579,54 @@ export default function EQPage() {
|
|||||||
schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
<div
|
<div
|
||||||
@@ -1627,7 +1740,7 @@ export default function EQPage() {
|
|||||||
<div
|
<div
|
||||||
ref={headphoneMenuRef}
|
ref={headphoneMenuRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative min-w-0 flex-1 max-w-[calc(100%-5.5rem)] rounded-[12px] overflow-visible backdrop-blur-md transition-all",
|
"relative min-w-0 flex-1 max-w-[calc(100%-9.5rem)] rounded-[12px] overflow-visible backdrop-blur-md transition-all",
|
||||||
isHeadphoneMenuOpen && "z-30"
|
isHeadphoneMenuOpen && "z-30"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1682,6 +1795,11 @@ export default function EQPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<PeqUndoButton
|
||||||
|
disabled={!canUndo}
|
||||||
|
label={eqUi.undo ?? "Undo"}
|
||||||
|
onClick={handleUndo}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
|
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
|
||||||
style={{
|
style={{
|
||||||
@@ -1733,6 +1851,8 @@ export default function EQPage() {
|
|||||||
prev.map((b, i) => (i === idx ? { ...b, ...patch } : b))
|
prev.map((b, i) => (i === idx ? { ...b, ...patch } : b))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onBandDragStart={() => beginGesture(takeUndoSnapshot())}
|
||||||
|
onBandDragEnd={() => commitGesture(takeUndoSnapshot())}
|
||||||
onBandSelect={setSelectedBand}
|
onBandSelect={setSelectedBand}
|
||||||
eqUi={eqUi}
|
eqUi={eqUi}
|
||||||
/>
|
/>
|
||||||
@@ -1827,6 +1947,11 @@ export default function EQPage() {
|
|||||||
}`}
|
}`}
|
||||||
style={active ? { background: "#00FFF6" } : undefined}
|
style={active ? { background: "#00FFF6" } : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (normalizeFilterType(band.type) === type) {
|
||||||
|
setIsFilterMenuOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureBefore(takeUndoSnapshot());
|
||||||
updateBand(selectedBand, { type });
|
updateBand(selectedBand, { type });
|
||||||
setIsFilterMenuOpen(false);
|
setIsFilterMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -1947,7 +2072,7 @@ export default function EQPage() {
|
|||||||
</span>
|
</span>
|
||||||
<IOSToggle
|
<IOSToggle
|
||||||
checked={autoPreOn}
|
checked={autoPreOn}
|
||||||
onChange={v => updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })}
|
onChange={handleAutoPreChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1957,6 +2082,8 @@ export default function EQPage() {
|
|||||||
max={30}
|
max={30}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
disabled={autoPreOn}
|
disabled={autoPreOn}
|
||||||
|
onInteractionStart={beginPreampSliderDrag}
|
||||||
|
onInteractionEnd={endPreampSliderDrag}
|
||||||
onChange={v =>
|
onChange={v =>
|
||||||
updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })
|
updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export function FreqChart({
|
|||||||
onApplyB,
|
onApplyB,
|
||||||
onSaveB,
|
onSaveB,
|
||||||
onBandDrag,
|
onBandDrag,
|
||||||
|
onBandDragStart,
|
||||||
|
onBandDragEnd,
|
||||||
onBandSelect,
|
onBandSelect,
|
||||||
eqUi,
|
eqUi,
|
||||||
}: {
|
}: {
|
||||||
@@ -53,6 +55,8 @@ export function FreqChart({
|
|||||||
onApplyB: () => void;
|
onApplyB: () => void;
|
||||||
onSaveB: () => void;
|
onSaveB: () => void;
|
||||||
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
||||||
|
onBandDragStart?: () => void;
|
||||||
|
onBandDragEnd?: () => void;
|
||||||
onBandSelect: (idx: number) => void;
|
onBandSelect: (idx: number) => void;
|
||||||
eqUi: PeqEqUi;
|
eqUi: PeqEqUi;
|
||||||
}) {
|
}) {
|
||||||
@@ -118,6 +122,7 @@ export function FreqChart({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
draggingBandRef.current = idx;
|
draggingBandRef.current = idx;
|
||||||
setIsBandDragging(true);
|
setIsBandDragging(true);
|
||||||
|
onBandDragStart?.();
|
||||||
onBandSelect(idx);
|
onBandSelect(idx);
|
||||||
updateBandFromPointer(idx, e.clientX, e.clientY);
|
updateBandFromPointer(idx, e.clientX, e.clientY);
|
||||||
};
|
};
|
||||||
@@ -130,8 +135,10 @@ export function FreqChart({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopDragging = () => {
|
const stopDragging = () => {
|
||||||
|
if (draggingBandRef.current === null) return;
|
||||||
draggingBandRef.current = null;
|
draggingBandRef.current = null;
|
||||||
setIsBandDragging(false);
|
setIsBandDragging(false);
|
||||||
|
onBandDragEnd?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const curveData = useMemo(
|
const curveData = useMemo(
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Undo2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PeqUndoButtonProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EQ page undo control — disabled when the undo stack is empty.
|
||||||
|
*/
|
||||||
|
export function PeqUndoButton({
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: PeqUndoButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full flex items-center justify-center transition-colors active:scale-95",
|
||||||
|
disabled
|
||||||
|
? "text-white/25 cursor-not-allowed"
|
||||||
|
: "text-white/70 active:text-white",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: disabled
|
||||||
|
? "rgba(44,44,46,0.45)"
|
||||||
|
: "rgba(44,44,46,0.8)",
|
||||||
|
border: disabled
|
||||||
|
? "1px solid rgba(255,255,255,0.06)"
|
||||||
|
: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Undo2 size={18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { cloneBands } from "../utils";
|
||||||
|
|
||||||
|
export const PEQ_UNDO_MAX_STEPS = 30;
|
||||||
|
|
||||||
|
export type PeqUndoBand = {
|
||||||
|
freq: number;
|
||||||
|
gain: number;
|
||||||
|
q: number;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeqUndoSnapshot = {
|
||||||
|
abMode: "A" | "B";
|
||||||
|
bandsByMode: { A: PeqUndoBand[]; B: PeqUndoBand[] };
|
||||||
|
preamp: number;
|
||||||
|
autoPre: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneSnapshot(snapshot: PeqUndoSnapshot): PeqUndoSnapshot {
|
||||||
|
return {
|
||||||
|
abMode: snapshot.abMode,
|
||||||
|
bandsByMode: {
|
||||||
|
A: cloneBands(snapshot.bandsByMode.A),
|
||||||
|
B: cloneBands(snapshot.bandsByMode.B),
|
||||||
|
},
|
||||||
|
preamp: snapshot.preamp,
|
||||||
|
autoPre: snapshot.autoPre,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peqUndoSnapshotsEqual(
|
||||||
|
a: PeqUndoSnapshot,
|
||||||
|
b: PeqUndoSnapshot,
|
||||||
|
): boolean {
|
||||||
|
if (a.abMode !== b.abMode) return false;
|
||||||
|
if (a.preamp !== b.preamp || a.autoPre !== b.autoPre) return false;
|
||||||
|
return (
|
||||||
|
JSON.stringify(a.bandsByMode.A) === JSON.stringify(b.bandsByMode.A) &&
|
||||||
|
JSON.stringify(a.bandsByMode.B) === JSON.stringify(b.bandsByMode.B)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo stack for EQ parameter edits.
|
||||||
|
* - One stack entry = one complete gesture (drag release / confirmed change)
|
||||||
|
* - Max {@link PEQ_UNDO_MAX_STEPS} entries
|
||||||
|
* - No redo
|
||||||
|
*/
|
||||||
|
export function usePeqUndoHistory(maxSteps = PEQ_UNDO_MAX_STEPS) {
|
||||||
|
const stackRef = useRef<PeqUndoSnapshot[]>([]);
|
||||||
|
const pendingBeforeRef = useRef<PeqUndoSnapshot | null>(null);
|
||||||
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
|
|
||||||
|
const syncCanUndo = useCallback(() => {
|
||||||
|
setCanUndo(stackRef.current.length > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
stackRef.current = [];
|
||||||
|
pendingBeforeRef.current = null;
|
||||||
|
setCanUndo(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(snapshot: PeqUndoSnapshot) => {
|
||||||
|
stackRef.current.push(cloneSnapshot(snapshot));
|
||||||
|
while (stackRef.current.length > maxSteps) {
|
||||||
|
stackRef.current.shift();
|
||||||
|
}
|
||||||
|
syncCanUndo();
|
||||||
|
},
|
||||||
|
[maxSteps, syncCanUndo],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Discrete edit: push current state as undo target before mutating. */
|
||||||
|
const captureBefore = useCallback(
|
||||||
|
(snapshot: PeqUndoSnapshot) => {
|
||||||
|
pendingBeforeRef.current = null;
|
||||||
|
push(snapshot);
|
||||||
|
},
|
||||||
|
[push],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Continuous gesture start (slider / curve drag). */
|
||||||
|
const beginGesture = useCallback((snapshot: PeqUndoSnapshot) => {
|
||||||
|
pendingBeforeRef.current = cloneSnapshot(snapshot);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Continuous gesture end: commit only if state actually changed. */
|
||||||
|
const commitGesture = useCallback(
|
||||||
|
(current: PeqUndoSnapshot) => {
|
||||||
|
const before = pendingBeforeRef.current;
|
||||||
|
pendingBeforeRef.current = null;
|
||||||
|
if (!before) return;
|
||||||
|
if (peqUndoSnapshotsEqual(before, current)) return;
|
||||||
|
push(before);
|
||||||
|
},
|
||||||
|
[push],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelGesture = useCallback(() => {
|
||||||
|
pendingBeforeRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undo = useCallback((): PeqUndoSnapshot | null => {
|
||||||
|
pendingBeforeRef.current = null;
|
||||||
|
const next = stackRef.current.pop() ?? null;
|
||||||
|
syncCanUndo();
|
||||||
|
return next ? cloneSnapshot(next) : null;
|
||||||
|
}, [syncCanUndo]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUndo,
|
||||||
|
clear,
|
||||||
|
captureBefore,
|
||||||
|
beginGesture,
|
||||||
|
commitGesture,
|
||||||
|
cancelGesture,
|
||||||
|
undo,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
formatBandFreqDisplay,
|
||||||
|
formatBandGainDisplay,
|
||||||
|
formatBandQDisplay,
|
||||||
|
eqInterp,
|
||||||
|
} from "./utils";
|
||||||
|
import type { PeqEqUi } from "./constants";
|
||||||
|
import type { PeqUndoBand, PeqUndoSnapshot } from "./hooks/usePeqUndoHistory";
|
||||||
|
|
||||||
|
export type PeqUndoDiffItem = {
|
||||||
|
/** Display label already localized where possible */
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAuto(on: boolean, eqUi: PeqEqUi): string {
|
||||||
|
return on ? (eqUi.undoAutoOn ?? "ON") : (eqUi.undoAutoOff ?? "OFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandParamLabel(
|
||||||
|
param: "freq" | "gain" | "q" | "type",
|
||||||
|
eqUi: PeqEqUi,
|
||||||
|
): string {
|
||||||
|
switch (param) {
|
||||||
|
case "freq":
|
||||||
|
return eqUi.undoParamFreq ?? "FREQ";
|
||||||
|
case "gain":
|
||||||
|
return eqUi.undoParamGain ?? "GAIN";
|
||||||
|
case "q":
|
||||||
|
return eqUi.undoParamQ ?? "Q";
|
||||||
|
case "type":
|
||||||
|
return eqUi.undoParamType ?? "Filter";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBandValue(
|
||||||
|
param: "freq" | "gain" | "q" | "type",
|
||||||
|
band: PeqUndoBand,
|
||||||
|
): string {
|
||||||
|
switch (param) {
|
||||||
|
case "freq":
|
||||||
|
return formatBandFreqDisplay(band.freq);
|
||||||
|
case "gain":
|
||||||
|
return formatBandGainDisplay(band.gain);
|
||||||
|
case "q":
|
||||||
|
return formatBandQDisplay(band.q);
|
||||||
|
case "type":
|
||||||
|
return String(band.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffBands(
|
||||||
|
fromBands: PeqUndoBand[],
|
||||||
|
toBands: PeqUndoBand[],
|
||||||
|
mode: "A" | "B",
|
||||||
|
eqUi: PeqEqUi,
|
||||||
|
): PeqUndoDiffItem[] {
|
||||||
|
const items: PeqUndoDiffItem[] = [];
|
||||||
|
const len = Math.max(fromBands.length, toBands.length);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const from = fromBands[i];
|
||||||
|
const to = toBands[i];
|
||||||
|
if (!from || !to) continue;
|
||||||
|
const params = ["freq", "gain", "q", "type"] as const;
|
||||||
|
for (const param of params) {
|
||||||
|
const fromVal = formatBandValue(param, from);
|
||||||
|
const toVal = formatBandValue(param, to);
|
||||||
|
if (fromVal === toVal) continue;
|
||||||
|
const text =
|
||||||
|
eqInterp(eqUi.undoBandParam, {
|
||||||
|
mode,
|
||||||
|
band: i + 1,
|
||||||
|
param: bandParamLabel(param, eqUi),
|
||||||
|
from: fromVal,
|
||||||
|
to: toVal,
|
||||||
|
}) ||
|
||||||
|
`${mode} Band ${i + 1} ${bandParamLabel(param, eqUi)} ${fromVal} → ${toVal}`;
|
||||||
|
items.push({ text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff current state (before clicking Undo) vs restored snapshot (after Undo).
|
||||||
|
* `from` is the value user just undid away from; `to` is the restored value.
|
||||||
|
*/
|
||||||
|
export function diffPeqUndoSnapshots(
|
||||||
|
current: PeqUndoSnapshot,
|
||||||
|
restored: PeqUndoSnapshot,
|
||||||
|
eqUi: PeqEqUi,
|
||||||
|
): PeqUndoDiffItem[] {
|
||||||
|
const items: PeqUndoDiffItem[] = [];
|
||||||
|
|
||||||
|
if (current.abMode !== restored.abMode) {
|
||||||
|
items.push({
|
||||||
|
text:
|
||||||
|
eqInterp(eqUi.undoSimpleParam, {
|
||||||
|
param: eqUi.undoParamAbMode ?? "A/B",
|
||||||
|
from: current.abMode,
|
||||||
|
to: restored.abMode,
|
||||||
|
}) ||
|
||||||
|
`A/B ${current.abMode} → ${restored.abMode}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.preamp !== restored.preamp) {
|
||||||
|
items.push({
|
||||||
|
text:
|
||||||
|
eqInterp(eqUi.undoSimpleParam, {
|
||||||
|
param: eqUi.undoParamPreamp ?? "Preamp",
|
||||||
|
from: formatBandGainDisplay(current.preamp),
|
||||||
|
to: formatBandGainDisplay(restored.preamp),
|
||||||
|
}) ||
|
||||||
|
`Preamp ${formatBandGainDisplay(current.preamp)} → ${formatBandGainDisplay(restored.preamp)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.autoPre !== restored.autoPre) {
|
||||||
|
items.push({
|
||||||
|
text:
|
||||||
|
eqInterp(eqUi.undoSimpleParam, {
|
||||||
|
param: eqUi.undoParamAuto ?? "AUTO",
|
||||||
|
from: formatAuto(current.autoPre === 1, eqUi),
|
||||||
|
to: formatAuto(restored.autoPre === 1, eqUi),
|
||||||
|
}) ||
|
||||||
|
`AUTO ${formatAuto(current.autoPre === 1, eqUi)} → ${formatAuto(restored.autoPre === 1, eqUi)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
...diffBands(current.bandsByMode.A, restored.bandsByMode.A, "A", eqUi),
|
||||||
|
);
|
||||||
|
items.push(
|
||||||
|
...diffBands(current.bandsByMode.B, restored.bandsByMode.B, "B", eqUi),
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build toast body for an undo action. */
|
||||||
|
export function formatPeqUndoToast(
|
||||||
|
current: PeqUndoSnapshot,
|
||||||
|
restored: PeqUndoSnapshot,
|
||||||
|
eqUi: PeqEqUi,
|
||||||
|
): string {
|
||||||
|
const diffs = diffPeqUndoSnapshots(current, restored, eqUi);
|
||||||
|
if (diffs.length === 0) {
|
||||||
|
return eqUi.toastUndoEmpty ?? eqUi.undo ?? "Undo";
|
||||||
|
}
|
||||||
|
if (diffs.length === 1) {
|
||||||
|
return (
|
||||||
|
eqInterp(eqUi.toastUndo, { detail: diffs[0]!.text }) ||
|
||||||
|
`Undone: ${diffs[0]!.text}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const summary = diffs
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(d => d.text)
|
||||||
|
.join(eqUi.undoDetailSep ?? "; ");
|
||||||
|
const extra =
|
||||||
|
diffs.length > 3
|
||||||
|
? eqInterp(eqUi.undoMoreSuffix, { count: diffs.length - 3 }) ||
|
||||||
|
` (+${diffs.length - 3})`
|
||||||
|
: "";
|
||||||
|
const detail =
|
||||||
|
eqInterp(eqUi.undoMultiple, {
|
||||||
|
count: diffs.length,
|
||||||
|
summary: `${summary}${extra}`,
|
||||||
|
}) || `${diffs.length} changes: ${summary}${extra}`;
|
||||||
|
return eqInterp(eqUi.toastUndo, { detail }) || `Undone: ${detail}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user