/* ============================================================ 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, Trash2, Copy, Edit3, Headphones, 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 { FreqChart } from "./eq/components/FreqChart"; import { CyanSlider, IOSToggle } from "./eq/components/EqPrimitives"; 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 { isPeqPresetNameTooLong } from "./eq/peqPresetName"; import { BAND_FREQ_MAX, BAND_FREQ_MIN, BAND_GAIN_MAX, BAND_GAIN_MIN, BAND_PARAM_VALUE_BOX_STYLE, BAND_Q_MAX, BAND_Q_MIN, DEFAULT_BANDS, FILTER_TYPES, FLAT_PRESET_FILTERS, PEQ_PRESET_MAX, } from "./eq/eqConstants"; import { eqInterp, formatBandFreqDisplay, formatBandGainDisplay, formatBandParamForInput, formatBandQDisplay, freqLabel, normalizeFilterType, normalizeBandFreqHz, parseBandParamInput, } from "./eq/eqFormatters"; import { bandToPeqFilter, buildPeqCatalogSyncKey, cloneBands, fetchEqSyncPeq, getUniquePresetName, importedFiltersToPeqFilters, } from "./eq/peqMappers"; import type { BandParamKind, PeqCatalogItem, PeqEqUi, PeqPresetLocalCache } from "./eq/types"; /* ── Main Component ── */ export default function EQPage() { const [, setLocation] = useLocation(); const { deviceState, peqState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply, syncPeqCatalog, } = 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(deviceState?.language); const peq = pack.peq as typeof localeZh.peq; return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi; }, [deviceState?.language]); const firmwareVersion = useMemo( () => parseFirmwareVersion(deviceState?.version), [deviceState?.version], ); const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion); const peqCardLabels = useMemo(() => { const pack = resolveLocalePack(deviceState?.language); return pack.peq ?? localeEn.peq; }, [deviceState?.language]); const eqPageTitle = usePageTitleWithConnection("HP-EQ"); const [bandsByMode, setBandsByMode] = useState>({ A: cloneBands(DEFAULT_BANDS), B: cloneBands(DEFAULT_BANDS), }); const [selectedBandByMode, setSelectedBandByMode] = useState>({ 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 [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 allowPeqRemoteSyncRef = useRef(false); const peqHydrationPendingRef = useRef(true); const lastPeqCatalogSyncKeyRef = useRef(""); const syncingHeadphoneRef = useRef(false); const peqSyncTimerRef = useRef(null); const skipPeqAutoSyncRef = useRef(false); /** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */ const skipBandsSyncFromAbToggleRef = useRef(false); /** 本地编辑过的预设(filters / preamp / autoPre);syncPeq 拉取不会覆盖 */ const peqPresetCacheRef = useRef>({}); const headphoneMenuRef = useRef(null); const filterMenuRef = useRef(null); const bands = bandsByMode[abMode]; const selectedBand = selectedBandByMode[abMode]; const setBandsForBothModes = useCallback((nextBands: typeof DEFAULT_BANDS) => { const cloned = cloneBands(nextBands); setBandsByMode({ A: cloneBands(cloned), B: cloneBands(cloned) }); }, []); const setBands = useCallback( (updater: typeof DEFAULT_BANDS | ((prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)) => { setBandsByMode((prev) => ({ ...prev, [abMode]: typeof updater === "function" ? (updater as (prev: typeof DEFAULT_BANDS) => typeof DEFAULT_BANDS)(prev[abMode]) : cloneBands(updater), })); }, [abMode], ); 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") => { setBandsByMode((prev) => ({ ...prev, [to]: cloneBands(prev[from]) })); setSelectedBandByMode((prev) => ({ ...prev, [to]: prev[from] })); }, []); const currentPeq = peqItems[headphoneIdx]; const currentPeqName = currentPeq?.name ?? ""; const currentPeqPreamp = currentPeq?.preamp ?? 0; const currentPeqAutoPre = currentPeq?.autoPre ?? 0; const preampValue = Number(currentPeqPreamp); const autoPreOn = currentPeqAutoPre === 1; 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 openAddHeadsetCatalog = () => { if (!guardPeqPresetCapacity()) return; setIsBrandDrawerOpen(true); setBrandDrawerKey((k) => k + 1); }; 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 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 rawFreq = f.fc ?? f.freq ?? f.frequency ?? 1000; const freq = normalizeBandFreqHz(rawFreq) ?? 1000; return { freq: Math.round(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 = Number(currentPeq?.preamp ?? 0); 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, currentPeq?.preamp]); 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 = () => { try { const parsed = parseBatchEditText(batchEditText); let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; setPeqItems((prev) => prev.map((item, i) => { if (i !== headphoneIdx) return item; const filters = parsed.filters.map(bandToPeqFilter); const merged = { ...item, autoPre: 0, preamp: parsed.preamp, filters, }; rememberPeqPresetCache(item.name, { filters, preamp: parsed.preamp, autoPre: 0 }); updatedPeq = merged; return merged; }) ); setBands(parsed.filters); schedulePeqSync(updatedPeq, parsed.filters, abMode, 0, "byMode", headphoneIdx); setSelectedBand(0); setIsBatchEditDialogOpen(false); toast.success(eqUi.toastBatchApplied); } catch (error) { toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError); } }; 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 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 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) { setHeadphoneIdx(0); return; } const nextIdx = Math.min( Math.max(devicePeqSelect ?? remote.peqSelect ?? 0, 0), items.length - 1, ); setHeadphoneIdx(nextIdx); }, [mergeRemotePeqCatalog], ); /** 将当前 A 曲线与总增益写回 peqItems 与本地缓存,切换耳机时才能恢复编辑结果 */ const persistPresetEditsToPeqItem = useCallback( ( catalogIdx: number, filtersSource: typeof DEFAULT_BANDS, meta?: { preamp?: number; autoPre?: number }, ) => { const filters = filtersSource.map(bandToPeqFilter); setPeqItems((prev) => prev.map((item, i) => { if (i !== catalogIdx) return item; const merged = { ...item, filters, ...(meta?.preamp !== undefined ? { preamp: meta.preamp } : {}), ...(meta?.autoPre !== undefined ? { autoPre: meta.autoPre } : {}), }; rememberPeqPresetCache(item.name, { filters, preamp: merged.preamp, autoPre: merged.autoPre, }); return merged; }), ); }, [rememberPeqPresetCache], ); const handleSelectHeadphone = useCallback( (idx: number) => { if (idx === headphoneIdx) { setIsHeadphoneMenuOpen(false); return; } const leaving = peqItems[headphoneIdx]; const leavingFilters = bandsByMode.A.map(bandToPeqFilter); rememberPeqPresetCache(leaving?.name, { filters: leavingFilters, preamp: leaving?.preamp, autoPre: leaving?.autoPre, }); persistPresetEditsToPeqItem(headphoneIdx, bandsByMode.A, { preamp: leaving?.preamp, autoPre: leaving?.autoPre, }); if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); peqSyncTimerRef.current = null; } setHeadphoneIdx(idx); updateSetting({ peqSelect: idx }); setIsHeadphoneMenuOpen(false); }, [ bandsByMode.A, headphoneIdx, peqItems, persistPresetEditsToPeqItem, rememberPeqPresetCache, updateSetting, ], ); 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); syncPeqCatalogFromState(remote, resolvedPeqSelect); const items = mergeRemotePeqCatalog((remote.peq ?? []) as PeqCatalogItem[]); if (items.length === 0) { setBandsForBothModes(DEFAULT_BANDS); setSelectedBandByMode({ A: 0, B: 0 }); return; } const nextIdx = Math.min( Math.max(resolvedPeqSelect, 0), items.length - 1, ); const nextBands = normalizeFiltersFromPeq(items[nextIdx] 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 { if (isDemoMode || !api) { const nextItems = peqItems.filter((_, idx) => idx !== target.idx); delete peqPresetCacheRef.current[target.name]; applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, target.idx - 1), }); toast.success(eqUi.toastDeleted); return; } delete peqPresetCacheRef.current[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: currentPeq?.autoPre, preamp: currentPeq?.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 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) { setHeadphoneIdx(createdIndex); updateSetting({ peqSelect: createdIndex }); } } else { const localFilters = addPresetMode === "copy" ? bands.map(bandToPeqFilter) : FLAT_PRESET_FILTERS; const localAutoPre = addPresetMode === "copy" ? (currentPeq?.autoPre ?? 0) : 0; const localPreamp = addPresetMode === "copy" ? (currentPeq?.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, }, ]); setHeadphoneIdx(headphoneModels.length); } 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: currentPeq?.autoPre, preamp: currentPeq?.preamp, canDel: currentPeq?.canDel, }, filters, ), }; try { 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) { setHeadphoneIdx(createdIndex); updateSetting({ peqSelect: createdIndex }); } } else { setHeadphoneModels((prev) => [...prev, nextName]); setPeqItems((prev) => [ ...prev, { name: nextName, filters, autoPre: currentPeq?.autoPre ?? 0, preamp: currentPeq?.preamp ?? 0, canDel: currentPeq?.canDel ?? 1, }, ]); setHeadphoneIdx(headphoneModels.length); } 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, ), }; skipPeqAutoSyncRef.current = true; try { 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) { setHeadphoneIdx(targetIdx); 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, ), ); setHeadphoneIdx(existingIdx); } 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, }, ]); setHeadphoneIdx(headphoneModels.length); } toast.success(eqUi.importSuccess); setIsShareDialogOpen(false); } catch { skipPeqAutoSyncRef.current = false; 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(() => { async function loadHeadphones() { peqHydrationPendingRef.current = true; allowPeqRemoteSyncRef.current = false; try { if (isDemoMode || !api) { // 演示模式使用默认列表 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: [] }))); setCurrentRawCurve(null); return; } try { const loadedPeq = await fetchEqSyncPeq(api, "loadHeadphones"); 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(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0), mergedPeq.length - 1, ); setHeadphoneIdx(nextIdx); lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.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 { // 如果没有数据,使用默认列表 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: [] }))); setCurrentRawCurve(null); } } catch { // 出错时使用默认列表 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: [] }))); setCurrentRawCurve(null); } } finally { allowPeqRemoteSyncRef.current = true; skipPeqAutoSyncRef.current = true; queueMicrotask(() => { peqHydrationPendingRef.current = false; }); } } loadHeadphones(); }, [api, isDemoMode, loadRawCurveForPeq, mergeRemotePeqCatalog, deviceState?.peqSelect]); /** 切换耳机型号时变;不含 filters,避免编辑时写回 peqItems 把 B 试听曲线重置 */ const headphoneSwitchKey = useMemo(() => { const peq = peqItems[headphoneIdx]; if (!peq) return String(headphoneIdx); return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001"); }, [headphoneIdx, peqItems]); // 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写) useEffect(() => { const peq = peqItems[headphoneIdx]; if (!peq) return; const nextBands = normalizeFiltersFromPeq(peq); if (!nextBands.length) return; syncingHeadphoneRef.current = true; 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); })(); requestAnimationFrame(() => { syncingHeadphoneRef.current = false; }); return () => { cancelled = true; }; }, [headphoneSwitchKey, loadRawCurveForPeq]); type PeqSubmitTopLevel = "peqChange" | "peqApply" | "byMode"; 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", catalogIdx = headphoneIdx, ) => { if (!allowPeqRemoteSyncRef.current) return; if (peqHydrationPendingRef.current) return; if (!isDemoMode && !api) return; if (!peq?.name) return; if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); } const idxAtSchedule = catalogIdx; peqSyncTimerRef.current = window.setTimeout(() => { const filters = filtersSource.map(bandToPeqFilter); const body = buildPeqPresetBody(peq, filters); const usePeqChange = topLevel === "peqChange" || (topLevel === "byMode" && mode === "A"); if (usePeqChange) { persistPresetEditsToPeqItem(idxAtSchedule, filtersSource, { preamp: peq.preamp, autoPre: peq.autoPre, }); } const submit = usePeqChange ? () => upgradePeqChange({ peqChange: body } satisfies PeqChangePayload) : () => upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload); submit().catch(() => { toast.error("EQ 保存失败"); }); }, delay); }, [api, headphoneIdx, isDemoMode, persistPresetEditsToPeqItem, upgradePeqApply, upgradePeqChange], ); /** 点击 A/B 切换试听:顶层固定 peqApply(A/B 数据结构相同,仅 filters 不同) */ const handleAbToggle = useCallback( (m: "A" | "B") => { if (m === abMode) return; skipBandsSyncFromAbToggleRef.current = true; setAbMode(m); schedulePeqSync(peqItems[headphoneIdx], bandsByMode[m], m, 0, "peqApply"); }, [abMode, bandsByMode, headphoneIdx, peqItems, schedulePeqSync], ); const handleCopyAndSwitchTo = useCallback( (to: "A" | "B") => { const from = abMode; const copied = cloneBands(bandsByMode[from]); copyModeParams(from, to); skipBandsSyncFromAbToggleRef.current = true; setAbMode(to); schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply"); }, [abMode, bandsByMode, copyModeParams, headphoneIdx, peqItems, schedulePeqSync], ); // 参数编辑防抖:A→peqChange,B→peqApply(不因切换 A/B 误触发) useEffect(() => { if (peqHydrationPendingRef.current) return; if (syncingHeadphoneRef.current) return; if (skipPeqAutoSyncRef.current) { skipPeqAutoSyncRef.current = false; return; } if (skipBandsSyncFromAbToggleRef.current) { skipBandsSyncFromAbToggleRef.current = false; return; } schedulePeqSync(peqItems[headphoneIdx], bands, abMode, 320, "byMode", headphoneIdx); return () => { if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); } }; }, [bands, abMode, headphoneIdx, currentPeqName, currentPeqPreamp, currentPeqAutoPre, schedulePeqSync]); 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]; useEffect(() => { setBandSliderPreview(null); }, [selectedBand, abMode, headphoneSwitchKey]); const handleApplyB = useCallback(async () => { const peq = peqItems[headphoneIdx]; if (!peq?.name) { toast.error(eqUi.toastApplyBFail); return; } 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, ), }; skipPeqAutoSyncRef.current = true; setBandsByMode((prev) => ({ ...prev, A: cloneBands(bBands) })); setSelectedBandByMode((prev) => ({ ...prev, A: prev.B })); rememberPeqPresetCache(peq.name, { filters, preamp: peq.preamp, autoPre: peq.autoPre, }); setPeqItems((prev) => prev.map((item, i) => (i !== headphoneIdx ? item : { ...item, filters })), ); setAbMode("A"); if (peqSyncTimerRef.current !== null) { window.clearTimeout(peqSyncTimerRef.current); peqSyncTimerRef.current = null; } try { await upgradePeqChange(payload); toast.success(eqUi.toastApplyBSuccess); } catch { skipPeqAutoSyncRef.current = false; toast.error(eqUi.toastApplyBFail); } }, [ bandsByMode.B, eqUi.toastApplyBFail, eqUi.toastApplyBSuccess, headphoneIdx, peqItems, upgradePeqChange, ]); const updateBand = useCallback( (idx: number, patch: Partial) => { 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; }, []); const endBandSliderDrag = useCallback( (kind: BandParamKind) => { if (bandSliderDragRef.current !== kind) return; bandSliderDragRef.current = null; clearBandSliderPreviewField(kind); }, [clearBandSliderPreviewField], ); 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; } 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 }>) => { let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; setPeqItems((prev) => { const next = prev.map((item, i) => { if (i !== headphoneIdx) return item; const merged = { ...item, ...patch }; updatedPeq = merged; rememberPeqPresetCache(item.name, patch); return merged; }); return next; }); schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx); }; 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))} 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({ autoPre: v ? 1 : 0 })} />
updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })} />
void handleSaveBAsPreset()} onClose={() => setIsSaveBDialogOpen(false)} eqUi={eqUi} /> setIsAddPresetDialogOpen(false)} eqUi={eqUi} /> setIsBatchEditDialogOpen(false)} eqUi={eqUi} /> 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; skipPeqAutoSyncRef.current = true; try { 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) { setHeadphoneIdx(targetIdx); 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, ), ); setHeadphoneIdx(existingIdx); } 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]; }); setHeadphoneIdx(nextIndex); } setIsBrandDrawerOpen(false); } catch { skipPeqAutoSyncRef.current = false; 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(); }} /> setIsPresetManageDialogOpen(false)} eqUi={eqUi} onDeletePresets={async (names) => { try { if (isDemoMode || !api) { const nextItems = peqItems.filter(it => !names.includes(it.name)); names.forEach(n => delete peqPresetCacheRef.current[n]); applyPeqStateToUI({ peq: nextItems, peqSelect: Math.min(headphoneIdx, Math.max(0, nextItems.length - 1)) }); toast.success(eqUi.toastDeleted); return true; } names.forEach(n => delete peqPresetCacheRef.current[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 { if (isDemoMode || !api) { const nextItems = peqItems.map(it => it.name === oldName ? { ...it, name: newName } : it ); delete peqPresetCacheRef.current[oldName]; applyPeqStateToUI({ peq: nextItems, peqSelect: headphoneIdx }); toast.success(eqUi.toastRenamed); return true; } const firmwareVersion = parseFirmwareVersion(deviceState?.version); if (supportsPeqPresetRenameAndSort(firmwareVersion)) { await api.renamePeq(oldName, newName); delete peqPresetCacheRef.current[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, }, }; // Legacy (< 29): peqChange then peqRemove await api.upgradePeqChange(payload); await new Promise(r => setTimeout(r, 300)); delete peqPresetCacheRef.current[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 { 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; } }} /> setIsShareDialogOpen(false)} peqItems={peqItems} headphoneIdx={headphoneIdx} eqUi={eqUi} peqCardLabels={peqCardLabels} mac={deviceState?.mac ?? ""} device={deviceState?.device ?? ""} isDemoMode={isDemoMode} onImportEq={handleImportSharedEq} />
); }