/* ============================================================ 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([]); 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( null ); const [bandParamInput, setBandParamInput] = useState(""); /** 滑块拖动时的实时显示(避免按钮文字滞后于 bands 状态) */ const [bandSliderPreview, setBandSliderPreview] = useState<{ freq?: number; gain?: number; q?: number; } | null>(null); const bandSliderDragRef = useRef(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; 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(null); const pendingPeqSyncRef = useRef<{ name: string; persist: boolean; run: () => Promise; } | null>(null); const inFlightPeqSyncRef = useRef>(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>({}); /** 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(null); const filterMenuRef = useRef(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): Promise => { 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; 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 => { 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 (
{/* ── Header ── */}

{eqPageTitle}

{ if (v) { void updateSetting( bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 } ); } else { void updateSetting({ peqEnable: 0 }); } }} />
{ const bypass = (deviceState?.dsp_enable ?? 0) === 0; return updateSetting( bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 } ); }} > {/* ── Action cards: 批量编辑 / 添加耳机型号 / 分享EQ ── */}
{/* ── Headphone model selector ── */}
{isHeadphoneMenuOpen && (
{headphoneModels.map((model, idx) => { const active = idx === headphoneIdx; return ( ); })}
)}
{/* ── Frequency response chart ── */} 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) ── */}
{bands.map((b, i) => ( ))}
{/* ── Band detail card ── */}
{/* Filter type selector */}
{peqCardLabels.filters?.label ?? "滤波器"}
{isFilterMenuOpen && (
{FILTER_TYPES.map(type => { const active = normalizeFilterType(band.type) === type; return ( ); })}
)}
{/* FREQ */}
FREQ
beginBandSliderDrag("freq")} onInteractionEnd={() => endBandSliderDrag("freq")} onChange={v => handleBandSliderChange("freq", Math.round(Math.pow(10, v))) } />
{/* GAIN */}
GAIN
beginBandSliderDrag("gain")} onInteractionEnd={() => endBandSliderDrag("gain")} onChange={v => handleBandSliderChange("gain", v)} />
{/* Q 值 */}
{peqCardLabels.q ?? "Q值"}
beginBandSliderDrag("q")} onInteractionEnd={() => endBandSliderDrag("q")} onChange={v => handleBandSliderChange("q", v)} />
{/* ── Total gain card ── */}
{peqCardLabels.preamp} {preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)} dB
AUTO
updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) }) } />
void handleSaveBAsPreset()} onClose={() => setIsSaveBDialogOpen(false)} eqUi={eqUi} /> setIsAddPresetDialogOpen(false)} eqUi={eqUi} /> { if (!batchSavingRef.current) setIsBatchEditDialogOpen(false); }} eqUi={eqUi} /> 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 => !!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; } }} /> 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) : 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); }} /> { void confirmDeleteHeadphone(); }} onCancel={() => setDeleteHeadphoneDialog(null)} /> { void overwritePresetDialog?.onConfirm(); }} onCancel={() => { overwritePresetDialog?.onDismiss(); }} /> setIsShareDialogOpen(false)} peqItems={peqItems} headphoneIdx={headphoneIdx} eqUi={eqUi} peqCardLabels={peqCardLabels} mac={deviceState?.mac ?? ""} device={deviceState?.device ?? ""} isDemoMode={isDemoMode} onImportEq={handleImportSharedEq} />
); }