Files
controller-v2/client/src/pages/EQPage.tsx
T
eafonyang acfcffa60d feat(locale): 实现前端多语言覆盖与生效语言逻辑
- 新增 effectiveLanguage 字段,统一界面语言解析依据
- 支持前端语言覆盖(德语/西班牙语/法语)优先显示,回退设备语言
- setUiLanguage 方法实现前端或设备语言切换逻辑与持久化
- 当设备语言发生变化时自动清除前端语言覆盖,保证同步一致
- BottomNav 组件和 ConnectionPlaceholder 组件调整使用 effectiveLanguage 获取语言包
- 多语言数据更新,新增德语、西班牙语、法语、韩语支持及语言选项扩展
2026-10-21 13:41:20 +08:00

2391 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
HP-EQ PAGE — Parametric EQ
Design: Reference luxsin_x8_hpeq_ui.jpeg
Layout (top to bottom):
1. Page header: < HP-EQ [toggle]
2. Two action cards: 批量编辑 / 添加耳机型号
3. Headphone model row: [Name ▼] [−] [+]
4. Freq response chart: legend + A/B + 复制到B + DIFF + SVG curve
5. Band grid: below lg breakpoint 2×5 pills; lg+ single row ×10
6. Band detail card: 滤波器 / FREQ / GAIN / Q值
7. Total gain card: 总增益 value + AUTO toggle + slider
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import {
ChevronLeft,
ChevronDown,
Edit3,
Headphones,
Trash2,
Copy,
Share2,
FilePenLine,
} from "lucide-react";
import { useLocation } from "wouter";
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import BottomNav from "@/components/BottomNav";
import { FeatureGate } from "@/components/FeatureGate";
import { toast } from "sonner";
import {
fetchLuxsinAudioCurve,
normalizePeqFiltersForSubmit,
parseFirmwareVersion,
type PeqFilter,
type PeqApplyPayload,
type PeqChangePayload,
type PeqState,
buildPeqPresetBody,
} from "@/lib/luxsinApi";
import { supportsPeqPresetRenameAndSort } from "@/config/firmwareFeatures";
import { getFilterType, getFilterShortName } from "@/lib/peqAudio";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
import { resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
import {
FILTER_TYPES,
BAND_FREQ_MIN,
BAND_FREQ_MAX,
BAND_GAIN_MIN,
BAND_GAIN_MAX,
BAND_Q_MIN,
BAND_Q_MAX,
BAND_PARAM_VALUE_BOX_STYLE,
DEFAULT_BANDS,
FLAT_BANDS,
FLAT_PRESET_FILTERS,
PEQ_PRESET_MAX,
type BandParamKind,
type PeqEqUi,
type PeqCatalogItem,
type PeqPresetLocalCache,
} from "./eq/constants";
import {
eqInterp,
formatBandFreqDisplay,
formatBandGainDisplay,
formatBandQDisplay,
formatBandParamForInput,
parseBandParamInput,
normalizeFilterType,
cloneBands,
freqLabel,
buildPeqCatalogSyncKey,
getUniquePresetName,
bandToPeqFilter,
fetchEqSyncPeq,
importedFiltersToPeqFilters,
} from "./eq/utils";
import { IOSToggle } from "./eq/components/IOSToggle";
import { CyanSlider } from "./eq/components/CyanSlider";
import { FreqChart } from "./eq/components/FreqChart";
import { BandParamDialog } from "./eq/components/BandParamDialog";
import { SaveBDialog } from "./eq/components/SaveBDialog";
import { AddPresetDialog } from "./eq/components/AddPresetDialog";
import { BatchEditDialog } from "./eq/components/BatchEditDialog";
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 { useRawCurve } from "./eq/hooks/useRawCurve";
import {
usePeqUndoHistory,
type PeqUndoSnapshot,
} from "./eq/hooks/usePeqUndoHistory";
import { formatPeqUndoToast } from "./eq/peqUndoDiff";
import { isPeqPresetNameTooLong } from "./eq/peqPresetName";
/* ── Default bands imported from eq/constants ── */
/* ── Main Component ── */
export default function EQPage() {
const [, setLocation] = useLocation();
const {
deviceState,
peqState,
updateSetting,
api,
isDemoMode,
upgradePeqChange,
upgradePeqApply,
syncPeqCatalog,
effectiveLanguage,
} = useDevice();
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
const peqOn = (deviceState?.peqEnable ?? 0) === 1;
const eqOn = peqOn && !bypassOn;
const eqUi = useMemo((): PeqEqUi => {
const pack = resolveLocalePack(effectiveLanguage);
const peq = pack.peq as typeof localeZh.peq;
return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi;
}, [effectiveLanguage]);
const firmwareVersion = useMemo(
() => parseFirmwareVersion(deviceState?.version),
[deviceState?.version],
);
const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion);
const peqCardLabels = useMemo(() => {
const pack = resolveLocalePack(effectiveLanguage);
return pack.peq ?? localeEn.peq;
}, [effectiveLanguage]);
const eqPageTitle = usePageTitleWithConnection("HP-EQ");
const [bandsByMode, setBandsByMode] = useState<
Record<"A" | "B", typeof DEFAULT_BANDS>
>({
A: cloneBands(FLAT_BANDS),
B: cloneBands(FLAT_BANDS),
});
const [selectedBandByMode, setSelectedBandByMode] = useState<
Record<"A" | "B", number>
>({
A: 0,
B: 0,
});
const [headphoneIdx, setHeadphoneIdx] = useState(deviceState?.peqSelect ?? 0);
const [headphoneModels, setHeadphoneModels] = useState<string[]>([]);
const [peqItems, setPeqItems] = useState<
Array<{
name: string;
brand?: string;
model?: string;
target?: string;
form?: string;
filters?: any[] | string;
autoPre?: number;
preamp?: number;
canDel?: number;
}>
>([]);
const [abMode, setAbMode] = useState<"A" | "B">("A");
const [isHeadphoneMenuOpen, setIsHeadphoneMenuOpen] = useState(false);
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isAddPresetDialogOpen, setIsAddPresetDialogOpen] = useState(false);
const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy");
const [copyPresetName, setCopyPresetName] = useState("");
const [flatPresetName, setFlatPresetName] = useState("");
const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false);
const [saveBPresetName, setSaveBPresetName] = useState("");
const [bandParamDialog, setBandParamDialog] = useState<BandParamKind | null>(
null
);
const [bandParamInput, setBandParamInput] = useState("");
/** 滑块拖动时的实时显示(避免按钮文字滞后于 bands 状态) */
const [bandSliderPreview, setBandSliderPreview] = useState<{
freq?: number;
gain?: number;
q?: number;
} | null>(null);
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);
const [shareDialogKey, setShareDialogKey] = useState(0);
const [isPresetManageDialogOpen, setIsPresetManageDialogOpen] =
useState(false);
const [overwritePresetDialog, setOverwritePresetDialog] = useState<{
name: string;
onConfirm: () => void | Promise<void>;
onDismiss: () => void;
} | null>(null);
const [deleteHeadphoneDialog, setDeleteHeadphoneDialog] = useState<{
name: string;
idx: number;
} | null>(null);
const {
currentRawCurve,
setCurrentRawCurve,
rawCurveLoading,
setRawCurveLoading,
loadRawCurveForPeq,
} = useRawCurve();
const {
canUndo,
clear: clearPeqUndo,
captureBefore,
beginGesture,
commitGesture,
undo: popPeqUndo,
} = usePeqUndoHistory();
const lastPeqCatalogSyncKeyRef = useRef("");
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 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 next = { A: cloneBands(nextBands), B: cloneBands(nextBands) };
bandsByModeRef.current = next;
setBandsByMode(next);
}, []);
const setSelectedBand = useCallback(
(updater: number | ((prev: number) => number)) => {
setSelectedBandByMode(prev => ({
...prev,
[abMode]:
typeof updater === "function"
? (updater as (prev: number) => number)(prev[abMode])
: updater,
}));
},
[abMode]
);
const copyModeParams = useCallback((from: "A" | "B", to: "A" | "B") => {
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 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 => {
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,
metaByMode: { A: { ...src.metaByMode.A }, B: { ...src.metaByMode.B } },
};
}, []);
const guardPeqPresetCapacity = () => {
if (peqItems.length < PEQ_PRESET_MAX) return true;
toast.error(
eqInterp(eqUi.toastPresetLimitReached, { max: PEQ_PRESET_MAX }) ||
`Preset limit reached (${PEQ_PRESET_MAX}). Delete some presets first.`
);
return false;
};
const promptOverwriteIfNeeded = useCallback(
(name: string, action: () => void | Promise<void>): Promise<boolean> => {
if (!headphoneModels.includes(name)) {
return Promise.resolve(action()).then(() => true);
}
return new Promise(resolve => {
setOverwritePresetDialog({
name,
onConfirm: async () => {
setOverwritePresetDialog(null);
await action();
resolve(true);
},
onDismiss: () => {
setOverwritePresetDialog(null);
resolve(false);
},
});
});
},
[headphoneModels]
);
const openAddHeadsetCatalog = () => {
if (!guardPeqPresetCapacity()) return;
setIsBrandDrawerOpen(true);
setBrandDrawerKey(k => k + 1);
};
const normalizeFiltersFromPeq = (
peq: { filters?: any[] | string } | undefined
) => {
if (!peq) return [] as typeof bands;
let filters: any[] = [];
if (typeof peq.filters === "string") {
try {
filters = JSON.parse(peq.filters);
} catch {
filters = [];
}
} else if (Array.isArray(peq.filters)) {
filters = peq.filters;
}
return filters.map((f: any) => {
const freq = f.fc || f.freq || f.frequency || 1000;
return {
freq: Number(freq),
gain: Number(f.gain || 0),
q: Number(f.q || 1),
type: normalizeFilterType(f.type),
enabled: f.enabled !== undefined ? f.enabled : true,
};
});
};
const toCompactNumber = (value: number) =>
Number(value.toFixed(3)).toString();
const buildBatchEditText = useCallback(() => {
const preamp = preampValue;
const source = bands.length > 0 ? bands : DEFAULT_BANDS;
const lines = [`Preamp:${toCompactNumber(preamp)}dB`];
for (let i = 0; i < 10; i++) {
const band = source[i] ?? DEFAULT_BANDS[i] ?? DEFAULT_BANDS[0];
lines.push(
`Filter ${i + 1}: ${band.enabled ? "ON" : "OFF"} ${normalizeFilterType(band.type)} Fc ${toCompactNumber(band.freq)} Hz Gain ${toCompactNumber(band.gain)} dB Q ${toCompactNumber(band.q)}`
);
}
return lines.join("\n");
}, [bands, preampValue]);
const openBatchEditDialog = () => {
setBatchEditText(buildBatchEditText());
setIsBatchEditDialogOpen(true);
};
const openPresetManageDialog = () => {
setIsPresetManageDialogOpen(true);
};
const parseBatchEditText = useCallback(
(raw: string) => {
const t = eqUi;
const lines = raw
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
if (lines.length < 11) {
throw new Error(eqInterp(t.errMinLines, {}));
}
const preampMatch = lines[0].match(
/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i
);
if (!preampMatch) {
throw new Error(eqInterp(t.errPreampFirstLine, {}));
}
const preamp = Number(preampMatch[1]);
if (!Number.isFinite(preamp)) {
throw new Error(eqInterp(t.errPreampValue, {}));
}
const parsedFilters = new Array<{
enabled: boolean;
type: string;
freq: number;
gain: number;
q: number;
}>(10);
for (let i = 1; i <= 10; i++) {
const line = lines[i];
const match = line.match(
/^Filter\s+(\d+)\s*:\s*(ON|OFF)\s+([A-Za-z]+)\s+Fc\s+([+-]?\d+(?:\.\d+)?)\s+Hz\s+Gain\s+([+-]?\d+(?:\.\d+)?)\s+dB\s+Q\s+([+-]?\d+(?:\.\d+)?)$/i
);
if (!match) {
throw new Error(eqInterp(t.errFilterLine, { line: String(i + 1) }));
}
const filterNo = Number(match[1]);
if (filterNo < 1 || filterNo > 10) {
throw new Error(
eqInterp(t.errFilterNoRange, { no: String(filterNo) })
);
}
const type = normalizeFilterType(match[3].toUpperCase());
if (!FILTER_TYPES.includes(type)) {
throw new Error(
eqInterp(t.errFilterType, { no: String(filterNo), type: match[3] })
);
}
const freq = Number(match[4]);
const gain = Number(match[5]);
const q = Number(match[6]);
if (![freq, gain, q].every(Number.isFinite)) {
throw new Error(
eqInterp(t.errFilterNumbers, { no: String(filterNo) })
);
}
parsedFilters[filterNo - 1] = {
enabled: match[2].toUpperCase() === "ON",
type,
freq: Number(freq.toFixed(2)),
gain: Number(gain.toFixed(2)),
q: Number(q.toFixed(2)),
};
}
if (parsedFilters.some(item => !item)) {
throw new Error(eqInterp(t.errFilterAll, {}));
}
return {
preamp: Number(preamp.toFixed(2)),
filters: parsedFilters as typeof bands,
};
},
[eqUi]
);
const handleSaveBatchEdit = async () => {
if (batchSavingRef.current) return;
let parsed: ReturnType<typeof parseBatchEditText>;
try {
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);
}
};
const rememberPeqPresetCache = useCallback(
(presetName: string | undefined, patch: PeqPresetLocalCache) => {
if (!presetName) return;
const prev = peqPresetCacheRef.current[presetName] ?? {};
const next: PeqPresetLocalCache = { ...prev, ...patch };
if (next.filters?.length === 0) delete next.filters;
peqPresetCacheRef.current[presetName] = next;
},
[]
);
const forgetPeqPresetCache = useCallback((presetName: string | undefined) => {
if (!presetName) return;
delete peqPresetCacheRef.current[presetName];
}, []);
const mergeRemotePeqCatalog = useCallback((items: PeqCatalogItem[]) => {
return items.map(item => {
const cached = peqPresetCacheRef.current[item.name];
if (!cached) return item;
return {
...item,
...(cached.filters?.length ? { filters: cached.filters } : {}),
...(cached.preamp !== undefined ? { preamp: cached.preamp } : {}),
...(cached.autoPre !== undefined ? { autoPre: cached.autoPre } : {}),
};
});
}, []);
const selectHeadphone = useCallback((idx: number, name?: string) => {
selectedPeqNameRef.current = name ?? "";
setHeadphoneIdx(idx);
}, []);
const syncPeqCatalogFromState = useCallback(
(
remote: { peq?: PeqCatalogItem[]; peqSelect?: number },
devicePeqSelect?: number
) => {
const items = mergeRemotePeqCatalog(remote.peq ?? []);
setPeqItems(items as typeof peqItems);
setHeadphoneModels(items.map(item => item.name));
if (items.length === 0) {
selectedPeqNameRef.current = "";
setHeadphoneIdx(0);
return 0;
}
const preferred = selectedPeqNameRef.current;
let nextIdx =
preferred !== ""
? items.findIndex(item => item.name === preferred)
: -1;
if (nextIdx < 0) {
nextIdx = Math.min(
Math.max(devicePeqSelect ?? remote.peqSelect ?? 0, 0),
items.length - 1
);
}
selectedPeqNameRef.current = items[nextIdx]?.name ?? "";
setHeadphoneIdx(nextIdx);
return nextIdx;
},
[mergeRemotePeqCatalog]
);
const applyPeqStateToUI = (
remote: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number; filters?: PeqFilter[] },
) => {
const resolvedPeqSelect =
remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect ?? 0;
if (remote.peq !== undefined) {
syncPeqCatalog({
filters: remote.filters ?? peqState?.filters ?? [],
peq: remote.peq,
peqSelect: resolvedPeqSelect,
});
}
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, resolvedPeqSelect);
const catalogIdx = syncPeqCatalogFromState(remote, resolvedPeqSelect);
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 });
return;
}
const nextBands = normalizeFiltersFromPeq(
items[catalogIdx] as { filters?: any[] | string },
);
if (nextBands.length > 0) {
setBandsForBothModes(nextBands);
}
setSelectedBandByMode({ A: 0, B: 0 });
};
const handleDeleteHeadphone = () => {
const target = peqItems[headphoneIdx];
if (!target?.name) return;
setDeleteHeadphoneDialog({ name: target.name, idx: headphoneIdx });
};
const confirmDeleteHeadphone = async () => {
const target = deleteHeadphoneDialog;
if (!target) return;
setDeleteHeadphoneDialog(null);
try {
await flushPendingPeqSync();
if (isDemoMode || !api) {
const nextItems = peqItems.filter((_, idx) => idx !== target.idx);
forgetPeqPresetCache(target.name);
applyPeqStateToUI({
peq: nextItems,
peqSelect: Math.max(0, target.idx - 1),
});
toast.success(eqUi.toastDeleted);
return;
}
forgetPeqPresetCache(target.name);
await api.removePeq([target.name]);
const latest = await fetchEqSyncPeq(api, "deleteHeadphone");
applyPeqStateToUI(latest);
toast.success(eqUi.toastDeleted);
} catch {
toast.error(eqUi.toastDeleteFail);
}
};
const openAddPresetDialog = () => {
if (!guardPeqPresetCapacity()) return;
const existingNames = headphoneModels;
const currentName = headphoneModels[headphoneIdx] ?? "Preset";
setCopyPresetName(getUniquePresetName(currentName, existingNames));
setFlatPresetName(getUniquePresetName("Flat", existingNames));
setAddPresetMode("copy");
setIsAddPresetDialogOpen(true);
};
const openSaveBDialog = () => {
const currentName =
peqItems[headphoneIdx]?.name ?? headphoneModels[headphoneIdx] ?? "Preset";
const defaultName = getUniquePresetName(
`${currentName}_B`,
headphoneModels
);
setSaveBPresetName(defaultName);
setIsSaveBDialogOpen(true);
};
const handleSaveAddPreset = async () => {
const nextName =
addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim();
if (!nextName) {
toast.error(eqUi.addPresetNameEmpty);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
}
const copyBrand = currentPeq?.brand?.trim() ?? "";
const copyModel = currentPeq?.model?.trim() ?? "";
const copyTarget = currentPeq?.target?.trim() ?? "";
const copyForm = currentPeq?.form?.trim() ?? "";
const copyPayload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: nextName,
brand: copyBrand || undefined,
model: copyModel || undefined,
target: copyTarget || undefined,
form: copyForm || undefined,
autoPre: activePeq?.autoPre,
preamp: activePeq?.preamp,
canDel: currentPeq?.canDel,
},
bands.map(bandToPeqFilter)
),
};
const flatPayload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{ name: nextName, preamp: 0, canDel: 1, autoPre: 0 },
FLAT_PRESET_FILTERS
),
};
try {
await flushPendingPeqSync();
await upgradePeqChange(
addPresetMode === "copy" ? copyPayload : flatPayload
);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "addPreset");
applyPeqStateToUI(
latest as {
peq?: Array<{ name: string; filters?: any[] | string }>;
peqSelect?: number;
}
);
const createdIndex =
latest.peq?.findIndex(item => item.name === nextName) ?? -1;
if (createdIndex >= 0) {
selectHeadphone(createdIndex, nextName);
updateSetting({ peqSelect: createdIndex });
}
} else {
const localFilters =
addPresetMode === "copy"
? bands.map(bandToPeqFilter)
: FLAT_PRESET_FILTERS;
const localAutoPre =
addPresetMode === "copy" ? (activePeq?.autoPre ?? 0) : 0;
const localPreamp =
addPresetMode === "copy" ? (activePeq?.preamp ?? 0) : 0;
setHeadphoneModels(prev => [...prev, nextName]);
setPeqItems(prev => [
...prev,
{
name: nextName,
...(addPresetMode === "copy" && copyBrand
? { brand: copyBrand }
: {}),
...(addPresetMode === "copy" && copyModel
? { model: copyModel }
: {}),
...(addPresetMode === "copy" && copyTarget
? { target: copyTarget }
: {}),
...(addPresetMode === "copy" && copyForm ? { form: copyForm } : {}),
filters: localFilters,
autoPre: localAutoPre,
preamp: localPreamp,
canDel: 1,
},
]);
selectHeadphone(headphoneModels.length, nextName);
}
setIsAddPresetDialogOpen(false);
toast.success(eqUi.toastAddPresetOk);
} catch {
toast.error(eqUi.toastAddPresetFail);
}
};
const handleSaveBAsPreset = async () => {
const nextName = saveBPresetName.trim();
if (!nextName) {
toast.error(eqUi.addPresetNameEmpty);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
}
const bBands = cloneBands(bandsByMode.B);
const filters = bBands.map(bandToPeqFilter);
const payload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: nextName,
brand: currentPeq?.brand,
model: currentPeq?.model,
target: currentPeq?.target,
form: currentPeq?.form,
autoPre: bPeqMeta?.autoPre,
preamp: bPeqMeta?.preamp,
canDel: currentPeq?.canDel,
},
filters
),
};
try {
await flushPendingPeqSync();
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "saveBAsPreset");
applyPeqStateToUI(
latest as {
peq?: Array<{ name: string; filters?: any[] | string }>;
peqSelect?: number;
}
);
const createdIndex =
latest.peq?.findIndex(item => item.name === nextName) ?? -1;
if (createdIndex >= 0) {
selectHeadphone(createdIndex, nextName);
updateSetting({ peqSelect: createdIndex });
}
} else {
setHeadphoneModels(prev => [...prev, nextName]);
setPeqItems(prev => [
...prev,
{
name: nextName,
filters,
autoPre: bPeqMeta?.autoPre ?? 0,
preamp: bPeqMeta?.preamp ?? 0,
canDel: currentPeq?.canDel ?? 1,
},
]);
selectHeadphone(headphoneModels.length, nextName);
}
setIsSaveBDialogOpen(false);
toast.success(eqUi.toastSaveBOk);
} catch {
toast.error(eqUi.toastSaveBFail);
}
};
const handleImportSharedEq = async (
data: {
name: string;
brand?: string;
model?: string;
target?: string;
form?: string;
filters?: unknown;
preamp?: number;
autoPre?: number;
},
shareCode: string
) => {
const nextName = data.name?.trim();
if (!nextName) {
toast.error(eqUi.addPresetNameEmpty);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (!shareCode.trim()) {
toast.error(eqUi.importCodeNotFound);
return;
}
if (!guardPeqPresetCapacity() && !headphoneModels.includes(nextName))
return;
const filters = importedFiltersToPeqFilters(data.filters);
if (filters.length === 0) {
toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail);
return;
}
const submitImport = async () => {
const existingIdx = headphoneModels.indexOf(nextName);
const isOverwrite = existingIdx >= 0;
const payload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: nextName,
brand: data.brand,
model: data.model,
target: data.target,
form: data.form,
preamp: data.preamp,
autoPre: data.autoPre ?? 0,
canDel: 1,
},
filters
),
};
try {
await flushPendingPeqSync();
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "importSharedEq");
applyPeqStateToUI(
latest as {
peq?: Array<{ name: string; filters?: any[] | string }>;
peqSelect?: number;
}
);
const targetIdx =
latest.peq?.findIndex(item => item.name === nextName) ?? -1;
if (targetIdx >= 0) {
selectHeadphone(targetIdx, nextName);
updateSetting({ peqSelect: targetIdx });
}
} else if (isOverwrite) {
setPeqItems(prev =>
prev.map((item, i) =>
i === existingIdx
? {
...item,
name: nextName,
brand: data.brand,
model: data.model,
target: data.target,
form: data.form,
filters,
preamp: data.preamp ?? 0,
autoPre: data.autoPre ?? 0,
canDel: 1,
}
: item
)
);
selectHeadphone(existingIdx, nextName);
} else {
setHeadphoneModels(prev => [...prev, nextName]);
setPeqItems(prev => [
...prev,
{
name: nextName,
brand: data.brand,
model: data.model,
target: data.target,
form: data.form,
filters,
preamp: data.preamp ?? 0,
autoPre: data.autoPre ?? 0,
canDel: 1,
},
]);
selectHeadphone(headphoneModels.length, nextName);
}
toast.success(eqUi.importSuccess);
setIsShareDialogOpen(false);
} catch {
toast.error(eqUi.importFail ?? eqUi.toastAddPresetFail);
}
};
await promptOverwriteIfNeeded(nextName, submitImport);
};
// msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项
useEffect(() => {
if (!peqState?.peq) return;
const resolvedPeqSelect = peqState.peqSelect ?? deviceState?.peqSelect;
const key = buildPeqCatalogSyncKey(peqState, resolvedPeqSelect);
if (!key || key === lastPeqCatalogSyncKeyRef.current) return;
lastPeqCatalogSyncKeyRef.current = key;
syncPeqCatalogFromState(peqState, resolvedPeqSelect);
}, [peqState, deviceState?.peqSelect, syncPeqCatalogFromState]);
// 切换耳机时默认回到 A 组对比
useEffect(() => {
setAbMode("A");
}, [headphoneIdx]);
// 加载耳机列表并初始化曲线
useEffect(() => {
let cancelled = false;
async function loadHeadphones() {
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 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。
}
} else {
// 设备未返回任何 EQ 预设:保持列表为空,避免展示虚假的默认型号
setHeadphoneModels([]);
setPeqItems([]);
selectHeadphone(0, "");
setBandsForBothModes(FLAT_BANDS);
setSelectedBandByMode({ A: 0, B: 0 });
setCurrentRawCurve(null);
}
} catch {
if (cancelled) return;
// 拉取失败:同样保持空列表,不展示虚假的默认型号
setHeadphoneModels([]);
setPeqItems([]);
selectHeadphone(0, "");
setCurrentRawCurve(null);
}
}
void loadHeadphones();
return () => {
cancelled = true;
};
}, [
api,
isDemoMode,
mergeRemotePeqCatalog,
selectHeadphone,
setCurrentRawCurve,
]);
/** 切换耳机型号时变;不含 filters,避免编辑写回 peqItems 后反复加载 bands 并触发 peqChange 循环 */
const headphoneSwitchKey = useMemo(() => {
const peq = peqItems[headphoneIdx];
if (!peq) return String(headphoneIdx);
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];
const meta = { preamp: peq?.preamp ?? 0, autoPre: peq?.autoPre ?? 0 };
bPeqMetaRef.current = meta;
setBPeqMeta(meta);
if (!peq) {
// 列表为空(如设备端删光全部预设):恢复平直曲线并清空 Raw 参考线
setBandsForBothModes(FLAT_BANDS);
setSelectedBandByMode({ A: 0, B: 0 });
setCurrentRawCurve(null);
setRawCurveLoading(false);
return;
}
const nextBands = normalizeFiltersFromPeq(peq);
if (!nextBands.length) return;
setBandsForBothModes(nextBands);
setSelectedBandByMode({ A: 0, B: 0 });
const shouldFetchModelCurve = !!(peq.brand?.trim() && peq.model?.trim());
if (shouldFetchModelCurve) {
setRawCurveLoading(true);
setCurrentRawCurve(null);
} else {
setRawCurveLoading(false);
}
let cancelled = false;
void (async () => {
const raw = await loadRawCurveForPeq(
peq as { brand?: string; model?: string }
);
if (cancelled) return;
setCurrentRawCurve(raw);
setRawCurveLoading(false);
})();
return () => {
cancelled = true;
};
}, [headphoneSwitchKey, loadRawCurveForPeq]);
useEffect(() => {
setBandSliderPreview(null);
}, [selectedBand, abMode, headphoneSwitchKey]);
type PeqSubmitTopLevel = "peqChange" | "peqApply" | "byMode";
const persistPresetEditsToPeqItem = useCallback(
(
name: string,
filtersSource: typeof DEFAULT_BANDS,
meta?: { preamp?: number; autoPre?: number },
) => {
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],
);
const handleSelectHeadphone = useCallback(
async (idx: number) => {
if (idx === headphoneIdx) {
setIsHeadphoneMenuOpen(false);
return;
}
const target = peqItems[idx];
if (!target) return;
try {
await flushPendingPeqSync();
await updateSetting({ peqSelect: idx });
selectHeadphone(idx, target.name);
setIsHeadphoneMenuOpen(false);
} catch {
toast.error("EQ 保存失败");
}
},
[flushPendingPeqSync, headphoneIdx, peqItems, selectHeadphone, updateSetting],
);
const schedulePeqSync = useCallback(
(
peq:
| {
name: string;
autoPre?: number;
preamp?: number;
canDel?: number;
brand?: string;
model?: string;
target?: string;
form?: string;
}
| undefined,
filtersSource: typeof DEFAULT_BANDS,
mode: "A" | "B",
delay = 320,
topLevel: PeqSubmitTopLevel = "byMode",
): 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 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(() => {
void runSync().catch(() => toast.error("EQ 保存失败"));
}, delay);
return Promise.resolve();
},
[
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 */
const handleAbToggle = useCallback(
(m: "A" | "B") => {
if (m === abMode) return;
// 切换试听前补发未完成的持久化同步,避免被随后的 peqApply 覆盖丢失
void flushPendingPeqSync().catch(() => toast.error("EQ 保存失败"));
setAbMode(m);
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, flushPendingPeqSync, headphoneIdx, peqItems, schedulePeqSync],
);
const handleCopyAndSwitchTo = useCallback(
(to: "A" | "B") => {
const from = abMode;
captureBefore(takeUndoSnapshot());
// 切换试听前补发未完成的持久化同步,避免被随后的 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);
setAbMode(to);
void schedulePeqSync(peq, copied, to, 0, "peqApply").catch(() => toast.error("EQ 保存失败"));
},
[
abMode,
bandsByMode,
captureBefore,
copyModeParams,
flushPendingPeqSync,
headphoneIdx,
peqItems,
schedulePeqSync,
takeUndoSnapshot,
],
);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
const inFilter = filterMenuRef.current?.contains(target) ?? false;
const inHeadphone = headphoneMenuRef.current?.contains(target) ?? false;
if (!inFilter) {
setIsFilterMenuOpen(false);
}
if (!inHeadphone) {
setIsHeadphoneMenuOpen(false);
}
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
const band =
bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
const handleApplyB = useCallback(async () => {
const item = peqItems[headphoneIdx];
const peq = item && { ...item, ...bPeqMetaRef.current };
if (!peq?.name) {
toast.error(eqUi.toastApplyBFail);
return;
}
captureBefore(takeUndoSnapshot());
const bBands = cloneBands(bandsByMode.B);
const filters = bBands.map(bandToPeqFilter);
const payload: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: peq.name,
brand: peq.brand,
model: peq.model,
target: peq.target,
form: peq.form,
autoPre: peq.autoPre,
preamp: peq.preamp,
canDel: peq.canDel,
},
filters,
),
};
const next = { ...bandsByModeRef.current, A: cloneBands(bBands) };
bandsByModeRef.current = next;
setBandsByMode(next);
setSelectedBandByMode((prev) => ({ ...prev, A: prev.B }));
setAbMode("A");
try {
await schedulePeqSync(payload.peqChange, bBands, "A", 0, "peqChange");
toast.success(eqUi.toastApplyBSuccess);
} catch {
toast.error(eqUi.toastApplyBFail);
}
}, [
bandsByMode.B,
captureBefore,
eqUi.toastApplyBFail,
eqUi.toastApplyBSuccess,
headphoneIdx,
peqItems,
schedulePeqSync,
takeUndoSnapshot,
]);
const updateBand = useCallback(
(idx: number, patch: Partial<(typeof DEFAULT_BANDS)[0]>) => {
setBands(prevBands =>
prevBands.map((b, i) => (i === idx ? { ...b, ...patch } : b))
);
},
[setBands]
);
const clearBandSliderPreviewField = useCallback((kind: BandParamKind) => {
setBandSliderPreview(prev => {
if (prev?.[kind] === undefined) return prev;
const next = { ...prev };
delete next[kind];
return next.freq !== undefined ||
next.gain !== undefined ||
next.q !== undefined
? next
: null;
});
}, []);
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, commitGesture, takeUndoSnapshot]
);
const handleBandSliderChange = useCallback(
(kind: BandParamKind, value: number) => {
if (bandSliderDragRef.current !== kind) return;
setBandSliderPreview(prev => ({ ...prev, [kind]: value }));
updateBand(selectedBand, { [kind]: value });
},
[selectedBand, updateBand]
);
const bandParamDialogMeta = useMemo(() => {
if (!bandParamDialog) return null;
const rangeMessages = {
invalid: eqUi.paramInputInvalid ?? "请输入有效数值",
outOfRange: "",
};
switch (bandParamDialog) {
case "freq":
return {
title: eqUi.paramInputTitleFreq ?? "设置频率",
hint:
eqUi.paramInputHintFreq ??
"范围:20 – 20000 Hz,可使用 k 表示 kHz(如 9.5k)",
placeholder: "9500",
inputMode: "decimal" as const,
rangeMessages: {
...rangeMessages,
outOfRange:
eqInterp(eqUi.paramInputOutOfRangeFreq, {
min: String(BAND_FREQ_MIN),
max: String(BAND_FREQ_MAX),
}) || `频率需在 ${BAND_FREQ_MIN} – ${BAND_FREQ_MAX} Hz 之间`,
},
};
case "gain":
return {
title: eqUi.paramInputTitleGain ?? "设置增益",
hint: eqUi.paramInputHintGain ?? "范围:-15.0 – +15.0 dB",
placeholder: "0.0",
inputMode: "decimal" as const,
rangeMessages: {
...rangeMessages,
outOfRange:
eqInterp(eqUi.paramInputOutOfRangeGain, {
min: BAND_GAIN_MIN.toFixed(1),
max: BAND_GAIN_MAX.toFixed(1),
}) || `增益需在 ${BAND_GAIN_MIN} – ${BAND_GAIN_MAX} dB 之间`,
},
};
case "q":
return {
title: eqUi.paramInputTitleQ ?? "设置 Q 值",
hint: eqUi.paramInputHintQ ?? "范围:0.10 – 10.00",
placeholder: "1.41",
inputMode: "decimal" as const,
rangeMessages: {
...rangeMessages,
outOfRange:
eqInterp(eqUi.paramInputOutOfRangeQ, {
min: BAND_Q_MIN.toFixed(2),
max: BAND_Q_MAX.toFixed(2),
}) || `Q 值需在 ${BAND_Q_MIN} – ${BAND_Q_MAX} 之间`,
},
};
}
}, [bandParamDialog, eqUi]);
const openBandParamDialog = (kind: BandParamKind) => {
setBandParamDialog(kind);
setBandParamInput(formatBandParamForInput(kind, band));
};
const closeBandParamDialog = () => {
setBandParamDialog(null);
setBandParamInput("");
};
const applyBandParamDialog = () => {
if (!bandParamDialog || !bandParamDialogMeta) return;
const parsed = parseBandParamInput(
bandParamDialog,
bandParamInput,
bandParamDialogMeta.rangeMessages
);
if (!parsed.ok) {
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 });
else updateBand(selectedBand, { q: parsed.value });
setBandSliderPreview(null);
closeBandParamDialog();
};
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 = () => {
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);
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);
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);
};
return (
<div className="min-h-screen bg-black">
<div
style={{
filter: eqOn ? "none" : "grayscale(1) opacity(0.4)",
transition: "filter 0.3s ease-in-out",
}}
>
{/* ── Header ── */}
<div className="page-header">
<button
onClick={() => setLocation("/")}
className="mr-4 text-white/60 active:text-white transition-colors"
>
<ChevronLeft size={24} />
</button>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">
{eqPageTitle}
</h1>
<IOSToggle
checked={eqOn}
onChange={v => {
if (v) {
void updateSetting(
bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }
);
} else {
void updateSetting({ peqEnable: 0 });
}
}}
/>
</div>
<FeatureGate
enabled={eqOn}
className="px-4 pb-32 pt-3 space-y-3"
labels={{
title: eqUi.enableConfirmTitle ?? "HP-EQ 未开启",
description:
eqUi.enableConfirmDesc ?? "当前 HP-EQ 功能已关闭,是否开启?",
cancel: eqUi.cancel ?? "取消",
confirm: eqUi.enableConfirmOk ?? "开启",
}}
onEnable={() => {
const bypass = (deviceState?.dsp_enable ?? 0) === 0;
return updateSetting(
bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }
);
}}
>
{/* ── Action cards: 批量编辑 / 添加耳机型号 / 分享EQ ── */}
<div className="grid grid-cols-3 gap-3">
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={openBatchEditDialog}
>
<div
className="w-10 h-10 rounded-[12px] flex items-center justify-center"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.2)",
}}
>
<Edit3 size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80">
{peqCardLabels.edit}
</span>
</button>
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={openAddHeadsetCatalog}
>
<div
className="w-10 h-10 rounded-[12px] flex items-center justify-center"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.2)",
}}
>
<Headphones size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80">
{peqCardLabels.headset}
</span>
</button>
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={() => {
setIsShareDialogOpen(true);
setShareDialogKey(k => k + 1);
}}
>
<div
className="w-10 h-10 rounded-[12px] flex items-center justify-center"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.2)",
}}
>
<Share2 size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80">
{peqCardLabels.share}
</span>
</button>
</div>
{/* ── Headphone model selector ── */}
<div className="flex min-w-0 items-center gap-2 px-1">
<div
ref={headphoneMenuRef}
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)",
boxShadow:
"0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
}}
>
<button
type="button"
className="w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide text-[#E9FBFF] bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px] truncate"
onClick={() => setIsHeadphoneMenuOpen(v => !v)}
title={headphoneModels[headphoneIdx] ?? ""}
>
{headphoneModels[headphoneIdx] ?? "—"}
</button>
<ChevronDown
size={13}
className={`absolute right-2.5 top-1/2 -translate-y-1/2 text-[#00FFF6] pointer-events-none transition-transform ${isHeadphoneMenuOpen ? "rotate-180" : ""}`}
/>
{isHeadphoneMenuOpen && (
<div
className="absolute left-0 right-0 mt-1 z-50 rounded-[10px] overflow-hidden max-h-56 overflow-y-auto"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}
>
{headphoneModels.map((model, idx) => {
const active = idx === headphoneIdx;
return (
<button
key={`${model}-${idx}`}
type="button"
className={`w-full px-3 py-2 text-left text-[13px] transition-colors truncate ${
active
? "text-black font-semibold"
: "text-white/90 hover:bg-white/10"
}`}
style={active ? { background: "#00FFF6" } : undefined}
title={model}
onClick={() => handleSelectHeadphone(idx)}
>
{model}
</button>
);
})}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<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)",
}}
onClick={openPresetManageDialog}
>
<FilePenLine size={18} />
</button>
<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)",
}}
onClick={handleDeleteHeadphone}
>
<Trash2 size={18} />
</button>
<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)",
}}
onClick={() => {
openAddPresetDialog();
}}
>
<Copy size={18} />
</button>
</div>
</div>
{/* ── Frequency response chart ── */}
<FreqChart
bands={bands}
rawCurve={currentRawCurve}
rawCurveLoading={rawCurveLoading}
selectedBand={selectedBand}
abMode={abMode}
onAbToggle={handleAbToggle}
onCopyAndSwitchTo={handleCopyAndSwitchTo}
canUndo={canUndo}
onUndo={handleUndo}
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}
/>
{/* ── Band grid: 2×5 below lg, 1×10 on lg+ (typical desktop) ── */}
<div className="ios-list-group p-3">
<div className="grid grid-cols-5 gap-1.5 lg:grid-cols-10 lg:gap-1">
{bands.map((b, i) => (
<button
key={i}
className={cn(
"flex flex-col items-center rounded-[10px] transition-all duration-150 active:scale-95 py-2.5 px-1 lg:py-2 lg:px-0.5",
selectedBand === i ? "text-black" : "text-white/55"
)}
style={
selectedBand === i
? {
background: "#00FFF6",
boxShadow: "0 0 14px rgba(0,255,246,0.45)",
}
: {
background: "rgba(44,44,46,0.65)",
}
}
onClick={() => setSelectedBand(i)}
>
<span
className="notranslate text-[12px] font-bold leading-tight tabular-nums lg:text-[11px]"
translate="no"
>
{freqLabel(b.freq)}
</span>
<span className="text-[9px] mt-0.5 opacity-70 leading-tight lg:text-[8px] lg:mt-0">
{getFilterShortName(b.type)}
</span>
</button>
))}
</div>
</div>
{/* ── Band detail card ── */}
<div className="ios-list-group p-4 space-y-5 relative z-30 overflow-visible">
{/* Filter type selector */}
<div className="flex items-center justify-between">
<span className="text-[14px] text-white/50 font-medium">
{peqCardLabels.filters?.label ?? "滤波器"}
</span>
<div
ref={filterMenuRef}
className={cn(
"relative w-[136px] rounded-[12px] overflow-visible backdrop-blur-md transition-all",
isFilterMenuOpen && "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)",
boxShadow:
"0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
}}
>
<button
type="button"
className="w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide text-[#E9FBFF] bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px]"
onClick={() => setIsFilterMenuOpen(v => !v)}
>
{normalizeFilterType(band.type)}
</button>
<ChevronDown
size={13}
className={`absolute right-2.5 top-1/2 -translate-y-1/2 text-[#00FFF6] pointer-events-none transition-transform ${isFilterMenuOpen ? "rotate-180" : ""}`}
/>
{isFilterMenuOpen && (
<div
className="absolute left-0 right-0 mt-1 z-50 rounded-[10px] overflow-hidden"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}
>
{FILTER_TYPES.map(type => {
const active = normalizeFilterType(band.type) === type;
return (
<button
key={type}
type="button"
className={`w-full px-3 py-2 text-left text-[13px] transition-colors ${
active
? "text-black font-semibold"
: "text-white/90 hover:bg-white/10"
}`}
style={active ? { background: "#00FFF6" } : undefined}
onClick={() => {
if (normalizeFilterType(band.type) === type) {
setIsFilterMenuOpen(false);
return;
}
captureBefore(takeUndoSnapshot());
updateBand(selectedBand, { type });
setIsFilterMenuOpen(false);
}}
>
{type}
</button>
);
})}
</div>
)}
</div>
</div>
{/* FREQ */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium tracking-wider">
FREQ
</span>
<button
type="button"
onClick={() => openBandParamDialog("freq")}
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
translate="no"
>
{formatBandFreqDisplay(bandSliderPreview?.freq ?? band.freq)}
</button>
</div>
<CyanSlider
value={Math.log10(bandSliderPreview?.freq ?? band.freq)}
min={Math.log10(BAND_FREQ_MIN)}
max={Math.log10(BAND_FREQ_MAX)}
step={0.005}
onInteractionStart={() => beginBandSliderDrag("freq")}
onInteractionEnd={() => endBandSliderDrag("freq")}
onChange={v =>
handleBandSliderChange("freq", Math.round(Math.pow(10, v)))
}
/>
</div>
{/* GAIN */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium tracking-wider">
GAIN
</span>
<button
type="button"
onClick={() => openBandParamDialog("gain")}
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
translate="no"
>
{formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)}
</button>
</div>
<CyanSlider
value={bandSliderPreview?.gain ?? band.gain}
min={BAND_GAIN_MIN}
max={BAND_GAIN_MAX}
step={0.1}
onInteractionStart={() => beginBandSliderDrag("gain")}
onInteractionEnd={() => endBandSliderDrag("gain")}
onChange={v => handleBandSliderChange("gain", v)}
/>
</div>
{/* Q 值 */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium">
{peqCardLabels.q ?? "Q值"}
</span>
<button
type="button"
onClick={() => openBandParamDialog("q")}
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
translate="no"
>
{formatBandQDisplay(bandSliderPreview?.q ?? band.q)}
</button>
</div>
<CyanSlider
value={bandSliderPreview?.q ?? band.q}
min={BAND_Q_MIN}
max={BAND_Q_MAX}
step={0.01}
onInteractionStart={() => beginBandSliderDrag("q")}
onInteractionEnd={() => endBandSliderDrag("q")}
onChange={v => handleBandSliderChange("q", v)}
/>
</div>
</div>
{/* ── Total gain card ── */}
<div className="ios-list-group p-4 relative z-10">
<div className="flex items-center justify-between mb-3">
<div className="flex items-baseline gap-1">
<span className="text-[13px] text-white/45">
{peqCardLabels.preamp}
</span>
<span
className="text-[28px] font-bold ml-2"
style={{ color: "#00FFF6" }}
>
{preampValue >= 0
? `+${preampValue.toFixed(1)}`
: preampValue.toFixed(1)}
</span>
<span className="text-[15px] text-white/45 ml-0.5">dB</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] text-white/40 tracking-wider">
AUTO
</span>
<IOSToggle
checked={autoPreOn}
onChange={handleAutoPreChange}
/>
</div>
</div>
<CyanSlider
value={preampValue + 15}
min={0}
max={30}
step={0.1}
disabled={autoPreOn}
onInteractionStart={beginPreampSliderDrag}
onInteractionEnd={endPreampSliderDrag}
onChange={v =>
updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })
}
/>
</div>
</FeatureGate>
</div>
<BandParamDialog
open={!!bandParamDialog && !!bandParamDialogMeta}
meta={bandParamDialogMeta}
input={bandParamInput}
onInputChange={setBandParamInput}
onConfirm={applyBandParamDialog}
onClose={closeBandParamDialog}
eqUi={eqUi}
/>
<SaveBDialog
open={isSaveBDialogOpen}
presetName={saveBPresetName}
onNameChange={setSaveBPresetName}
onSave={() => void handleSaveBAsPreset()}
onClose={() => setIsSaveBDialogOpen(false)}
eqUi={eqUi}
/>
<AddPresetDialog
open={isAddPresetDialogOpen}
mode={addPresetMode}
copyName={copyPresetName}
flatName={flatPresetName}
onModeChange={setAddPresetMode}
onCopyNameChange={setCopyPresetName}
onFlatNameChange={setFlatPresetName}
onSave={handleSaveAddPreset}
onClose={() => setIsAddPresetDialogOpen(false)}
eqUi={eqUi}
/>
<BatchEditDialog
open={isBatchEditDialogOpen}
text={batchEditText}
onChangeText={setBatchEditText}
onSave={handleSaveBatchEdit}
saving={batchSaving}
onClose={() => { if (!batchSavingRef.current) setIsBatchEditDialogOpen(false); }}
eqUi={eqUi}
/>
<PeqPresetManageDialog
open={isPresetManageDialogOpen}
items={peqItems}
selectedIdx={headphoneIdx}
reorderEnabled={peqReorderEnabled}
onClose={() => setIsPresetManageDialogOpen(false)}
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));
applyPeqStateToUI({
peq: nextItems,
peqSelect: Math.min(headphoneIdx, Math.max(0, nextItems.length - 1)),
});
toast.success(eqUi.toastDeleted);
return true;
}
names.forEach(n => forgetPeqPresetCache(n));
await api.removePeq(names);
const latest = await fetchEqSyncPeq(api, "deletePresets");
applyPeqStateToUI(latest);
toast.success(eqUi.toastDeleted);
return true;
} catch {
toast.error(eqUi.toastDeleteFail);
return false;
}
}}
onRenamePreset={async (oldName, newName, item) => {
try {
await flushPendingPeqSync();
item = { ...item, ...peqPresetCacheRef.current[oldName] };
if (selectedPeqNameRef.current === oldName) {
selectedPeqNameRef.current = newName;
}
if (isDemoMode || !api) {
const nextItems = peqItems.map(it =>
it.name === oldName ? { ...it, name: newName } : it
);
forgetPeqPresetCache(oldName);
applyPeqStateToUI({ peq: nextItems, peqSelect: headphoneIdx });
toast.success(eqUi.toastRenamed);
return true;
}
if (supportsPeqPresetRenameAndSort(firmwareVersion)) {
await api.renamePeq(oldName, newName);
forgetPeqPresetCache(oldName);
const latest = await fetchEqSyncPeq(api, "renamePreset");
applyPeqStateToUI(latest);
toast.success(eqUi.toastRenamed);
return true;
}
const rawFilters = normalizePeqFiltersForSubmit(item.filters);
const filters = rawFilters.map(f => ({
...f,
type: getFilterType(f.type),
}));
const payload: PeqChangePayload = {
peqChange: {
name: newName,
filters,
autoPre: item.autoPre,
preamp: item.preamp,
canDel: item.canDel ?? 1,
brand: item.brand,
model: item.model,
target: item.target,
form: item.form,
},
};
await api.upgradePeqChange(payload);
await new Promise(r => setTimeout(r, 300));
forgetPeqPresetCache(oldName);
await api.removePeq([oldName]);
await new Promise(r => setTimeout(r, 300));
const latest = await fetchEqSyncPeq(api, "renamePreset");
applyPeqStateToUI(latest);
toast.success(eqUi.toastRenamed);
return true;
} catch {
toast.error(eqUi.toastRenameFail);
return false;
}
}}
onConfirmOrder={async (names, peqSelect) => {
try {
await flushPendingPeqSync();
if (isDemoMode || !api) {
const byName = new Map(peqItems.map(it => [it.name, it]));
const nextItems = names
.map(name => byName.get(name))
.filter((it): it is NonNullable<typeof it> => !!it);
applyPeqStateToUI({ peq: nextItems, peqSelect });
toast.success(eqUi.toastOrderSaved);
return true;
}
await api.sortPeq(names, peqSelect);
const latest = await fetchEqSyncPeq(api, "sortPresets");
const nextSelect = latest.peqSelect ?? peqSelect;
applyPeqStateToUI({ ...latest, peqSelect: nextSelect });
updateSetting({ peqSelect: nextSelect });
toast.success(eqUi.toastOrderSaved);
return true;
} catch {
toast.error(eqUi.toastOrderFail);
return false;
}
}}
/>
<BrandDrawer
key={`brand-drawer-${brandDrawerKey}`}
open={isBrandDrawerOpen}
onClose={() => setIsBrandDrawerOpen(false)}
eqUi={eqUi}
onConfirm={async (brand, name, target, form) => {
const decoded = await fetchLuxsinAudioCurve(brand, name, target);
let parsed: unknown = decoded;
try {
parsed = JSON.parse(decoded);
} catch {
// Keep raw decoded text when payload isn't JSON.
}
const parsedObj =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: null;
const parametricEqRaw = parsedObj?.parametric_eq;
if (!parametricEqRaw || typeof parametricEqRaw !== "object") {
throw new Error("parametric_eq missing");
}
const parametricEq = parametricEqRaw as {
filters?: Array<{
type: string | number;
fc: number;
gain: number;
q: number;
}>;
preamp?: number;
};
const filters = (
Array.isArray(parametricEq.filters) ? parametricEq.filters : []
)
.slice(0, 10)
.map(item => ({
type: getFilterType(item.type),
fc: Number(Number(item.fc).toFixed(2)),
gain: Number(Number(item.gain).toFixed(2)),
q: Number(Number(item.q).toFixed(2)),
}));
const createdName = `${brand} ${name}`;
const postPeq: PeqChangePayload = {
peqChange: buildPeqPresetBody(
{
name: createdName,
brand,
model: name,
target,
form,
preamp: Number(Number(parametricEq.preamp ?? 0).toFixed(2)),
autoPre: 0,
canDel: 1,
},
filters
),
};
const submitCatalogPreset = async () => {
const existingIdx = headphoneModels.indexOf(createdName);
const isOverwrite = existingIdx >= 0;
if (!isOverwrite && !guardPeqPresetCapacity()) return;
try {
await flushPendingPeqSync();
await upgradePeqChange(postPeq);
if (api && !isDemoMode) {
const latest = await fetchEqSyncPeq(api, "addCatalogPreset");
applyPeqStateToUI(
latest as {
peq?: Array<{ name: string; filters?: any[] | string }>;
peqSelect?: number;
}
);
const targetIdx =
latest.peq?.findIndex(item => item.name === createdName) ??
-1;
if (targetIdx >= 0) {
selectHeadphone(targetIdx, createdName);
updateSetting({ peqSelect: targetIdx });
}
} else if (isOverwrite) {
setPeqItems(prev =>
prev.map((item, i) =>
i === existingIdx
? {
...item,
name: createdName,
brand,
model: name,
target,
...(form ? { form } : {}),
filters: postPeq.peqChange.filters,
preamp: postPeq.peqChange.preamp ?? 0,
autoPre: postPeq.peqChange.autoPre ?? 0,
canDel: postPeq.peqChange.canDel ?? 1,
}
: item
)
);
selectHeadphone(existingIdx, createdName);
} else {
let nextIndex = 0;
setPeqItems(prev => [
...prev,
{
name: createdName,
brand,
model: name,
target,
...(form ? { form } : {}),
filters: postPeq.peqChange.filters,
preamp: postPeq.peqChange.preamp ?? 0,
autoPre: postPeq.peqChange.autoPre ?? 0,
canDel: postPeq.peqChange.canDel ?? 1,
},
]);
setHeadphoneModels(prev => {
nextIndex = prev.length;
return [...prev, createdName];
});
selectHeadphone(nextIndex, createdName);
}
setIsBrandDrawerOpen(false);
} catch {
toast.error(eqUi.toastAddPresetFail);
}
};
if (headphoneModels.includes(createdName)) {
setIsBrandDrawerOpen(false);
}
return promptOverwriteIfNeeded(createdName, submitCatalogPreset);
}}
/>
<PeqOverwriteConfirmDialog
open={deleteHeadphoneDialog !== null}
title={eqUi.deletePreset}
description={
deleteHeadphoneDialog
? eqInterp(eqUi.deleteConfirm, { name: deleteHeadphoneDialog.name })
: ""
}
cancelLabel={eqUi.cancel}
confirmLabel={eqUi.deletePreset}
variant="destructive"
onConfirm={() => {
void confirmDeleteHeadphone();
}}
onCancel={() => setDeleteHeadphoneDialog(null)}
/>
<PeqOverwriteConfirmDialog
open={!!overwritePresetDialog}
title={eqUi.overwritePresetTitle ?? "Preset name already exists"}
description={
overwritePresetDialog
? eqInterp(eqUi.overwritePresetDesc, {
name: overwritePresetDialog.name,
})
: ""
}
cancelLabel={eqUi.cancel}
confirmLabel={
eqUi.overwritePresetConfirm ?? eqUi.confirmButton ?? eqUi.save
}
onConfirm={() => {
void overwritePresetDialog?.onConfirm();
}}
onCancel={() => {
overwritePresetDialog?.onDismiss();
}}
/>
<ShareDialog
key={`share-dialog-${shareDialogKey}`}
open={isShareDialogOpen}
onClose={() => setIsShareDialogOpen(false)}
peqItems={peqItems}
headphoneIdx={headphoneIdx}
eqUi={eqUi}
peqCardLabels={peqCardLabels}
mac={deviceState?.mac ?? ""}
device={deviceState?.device ?? ""}
isDemoMode={isDemoMode}
onImportEq={handleImportSharedEq}
/>
<BottomNav />
</div>
);
}