acfcffa60d
- 新增 effectiveLanguage 字段,统一界面语言解析依据 - 支持前端语言覆盖(德语/西班牙语/法语)优先显示,回退设备语言 - setUiLanguage 方法实现前端或设备语言切换逻辑与持久化 - 当设备语言发生变化时自动清除前端语言覆盖,保证同步一致 - BottomNav 组件和 ConnectionPlaceholder 组件调整使用 effectiveLanguage 获取语言包 - 多语言数据更新,新增德语、西班牙语、法语、韩语支持及语言选项扩展
2391 lines
81 KiB
TypeScript
2391 lines
81 KiB
TypeScript
/* ============================================================
|
||
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>
|
||
);
|
||
}
|