Files
controller-v2/client/src/pages/EQPage.tsx
T

2259 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
HP-EQ PAGE — Parametric EQ
Design: Reference luxsin_x8_hpeq_ui.jpeg
Layout (top to bottom):
1. Page header: < HP-EQ [toggle]
2. Two action cards: 批量编辑 / 添加耳机型号
3. Headphone model row: [Name ▼] [] [+]
4. Freq response chart: legend + A/B + 复制到B + DIFF + SVG curve
5. Band grid: below lg breakpoint 2×5 pills; lg+ single row ×10
6. Band detail card: 滤波器 / FREQ / GAIN / Q值
7. Total gain card: 总增益 value + AUTO toggle + slider
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X } 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 {
decodeCustomBase64,
fetchLuxsinAudioBrands,
fetchLuxsinAudioCurve,
fetchLuxsinAudioModelList,
fetchLuxsinAudioModels,
type LuxsinAudioBrand,
type LuxsinAudioModelListItem,
type LuxsinAudioModel,
type PeqFilter,
type PeqApplyPayload,
type PeqChangePayload,
type PeqPresetBody,
type PeqState,
} from "@/lib/luxsinApi";
import * as echarts from "echarts";
import { getSectionsMatrix, visualizeResponse, getChartOps, getFilterType, getFilterShortName } from "@/lib/peqAudio";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
import { FreqChart } from "./eq/components/FreqChart";
import { CyanSlider, IOSToggle } from "./eq/components/EqPrimitives";
import {
BAND_FREQ_MAX,
BAND_FREQ_MIN,
BAND_GAIN_MAX,
BAND_GAIN_MIN,
BAND_PARAM_VALUE_BOX_STYLE,
BAND_Q_MAX,
BAND_Q_MIN,
CATALOG_TARGETS,
DEFAULT_BANDS,
FILTER_TYPES,
FLAT_PRESET_FILTERS,
} from "./eq/eqConstants";
import {
eqInterp,
formatBandFreqDisplay,
formatBandGainDisplay,
formatBandParamForInput,
formatBandQDisplay,
freqLabel,
normalizeFilterType,
normalizeBandFreqHz,
parseBandParamInput,
} from "./eq/eqFormatters";
import { bandToPeqFilter, buildPeqCatalogSyncKey, cloneBands, getUniquePresetName } 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 lang = deviceState?.language;
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
const peq = pack.peq as typeof localeZh.peq;
return (peq.eqUi ?? (localeZh.peq as typeof localeZh.peq).eqUi) as PeqEqUi;
}, [deviceState?.language]);
const peqCardLabels = useMemo(() => {
const lang = deviceState?.language;
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
return pack.peq ?? localeZh.peq;
}, [deviceState?.language]);
const [bandsByMode, setBandsByMode] = useState<Record<"A" | "B", typeof DEFAULT_BANDS>>({
A: cloneBands(DEFAULT_BANDS),
B: cloneBands(DEFAULT_BANDS),
});
const [selectedBandByMode, setSelectedBandByMode] = useState<Record<"A" | "B", number>>({
A: 0,
B: 0,
});
const [headphoneIdx, setHeadphoneIdx] = useState(deviceState?.peqSelect ?? 0);
const [headphoneModels, setHeadphoneModels] = useState<string[]>([]);
const [peqItems, setPeqItems] = useState<
Array<{
name: string;
brand?: string;
model?: string;
filters?: any[] | string;
autoPre?: number;
preamp?: number;
canDel?: number;
}>
>([]);
const [abMode, setAbMode] = useState<"A" | "B">("A");
const [isHeadphoneMenuOpen, setIsHeadphoneMenuOpen] = useState(false);
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isAddPresetDialogOpen, setIsAddPresetDialogOpen] = useState(false);
const [addPresetMode, setAddPresetMode] = useState<"copy" | "flat">("copy");
const [copyPresetName, setCopyPresetName] = useState("");
const [flatPresetName, setFlatPresetName] = useState("");
const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false);
const [saveBPresetName, setSaveBPresetName] = useState("");
const [bandParamDialog, setBandParamDialog] = useState<BandParamKind | null>(null);
const [bandParamInput, setBandParamInput] = useState("");
/** 滑块拖动时的实时显示(避免按钮文字滞后于 bands 状态) */
const [bandSliderPreview, setBandSliderPreview] = useState<{
freq?: number;
gain?: number;
q?: number;
} | null>(null);
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
const [batchEditText, setBatchEditText] = useState("");
type BrandDrawerTab = "brands" | "models" | "target";
const [isBrandDrawerOpen, setIsBrandDrawerOpen] = useState(false);
const [brandDrawerTab, setBrandDrawerTab] = useState<BrandDrawerTab>("brands");
const [brandSearchQuery, setBrandSearchQuery] = useState("");
const [catalogBrands, setCatalogBrands] = useState<LuxsinAudioBrand[]>([]);
const [catalogBrandsLoading, setCatalogBrandsLoading] = useState(false);
const [catalogBrandsError, setCatalogBrandsError] = useState<string | null>(null);
const [catalogSearchResults, setCatalogSearchResults] = useState<LuxsinAudioModelListItem[]>([]);
const [catalogSearchLoading, setCatalogSearchLoading] = useState(false);
const [catalogSearchError, setCatalogSearchError] = useState<string | null>(null);
const [selectedCatalogBrand, setSelectedCatalogBrand] = useState<string>("");
const [catalogModels, setCatalogModels] = useState<LuxsinAudioModel[]>([]);
const [catalogModelsLoading, setCatalogModelsLoading] = useState(false);
const [catalogModelsError, setCatalogModelsError] = useState<string | null>(null);
const [selectedCatalogModelFromSearch, setSelectedCatalogModelFromSearch] = useState<string>("");
const [selectedCatalogModelName, setSelectedCatalogModelName] = useState("");
const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState<string | undefined>(undefined);
const [selectedCatalogTarget, setSelectedCatalogTarget] = useState<string>("");
const [isConfirmingTarget, setIsConfirmingTarget] = useState(false);
const [currentRawCurve, setCurrentRawCurve] = useState<number[] | null>(null);
const allowPeqRemoteSyncRef = useRef(false);
const lastPeqCatalogSyncKeyRef = useRef("");
const syncingHeadphoneRef = useRef(false);
const peqSyncTimerRef = useRef<number | null>(null);
const skipPeqAutoSyncRef = useRef(false);
/** A/B 切换已单独下发,避免 bands 变更触发的 effect 用错 peqChange/peqApply */
const skipBandsSyncFromAbToggleRef = useRef(false);
/** 本地编辑过的预设(filters / preamp / autoPre);syncPeq 拉取不会覆盖 */
const peqPresetCacheRef = useRef<Record<string, PeqPresetLocalCache>>({});
const headphoneMenuRef = useRef<HTMLDivElement | null>(null);
const filterMenuRef = useRef<HTMLDivElement | null>(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 filteredCatalogBrands = useMemo(() => {
const q = brandSearchQuery.trim().toLowerCase();
if (!q) return catalogBrands;
return catalogBrands.filter((b) => b.name.toLowerCase().includes(q));
}, [catalogBrands, brandSearchQuery]);
const availableCatalogTargets = useMemo(() => {
if (selectedCatalogModelForm === "in-ear") {
return CATALOG_TARGETS.filter((item) => item.ear === "in" || item.ear === "all");
}
if (selectedCatalogModelForm === "over-ear") {
return CATALOG_TARGETS.filter((item) => item.ear === "over" || item.ear === "all");
}
return CATALOG_TARGETS;
}, [selectedCatalogModelForm]);
const loadCatalogBrands = useCallback(async () => {
setCatalogBrandsLoading(true);
setCatalogBrandsError(null);
try {
const list = await fetchLuxsinAudioBrands();
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
setCatalogBrands(sorted);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
setCatalogBrandsError(msg);
toast.error(eqUi.toastBrandsFail);
} finally {
setCatalogBrandsLoading(false);
}
}, [eqUi]);
const searchCatalogByKeyword = useCallback(async (keyword: string) => {
const q = keyword.trim();
if (!q) {
setCatalogSearchResults([]);
setCatalogSearchError(null);
setCatalogSearchLoading(false);
return;
}
setCatalogSearchLoading(true);
setCatalogSearchError(null);
try {
const list = await fetchLuxsinAudioModelList(q, 1000);
setCatalogSearchResults(list);
} catch (e) {
const msg = e instanceof Error ? e.message : "搜索失败";
setCatalogSearchError(msg);
setCatalogSearchResults([]);
} finally {
setCatalogSearchLoading(false);
}
}, []);
const loadCatalogModels = useCallback(async (brandName: string) => {
setSelectedCatalogModelFromSearch("");
setCatalogModelsLoading(true);
setCatalogModelsError(null);
try {
const list = await fetchLuxsinAudioModels(brandName);
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
setCatalogModels(sorted);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
setCatalogModelsError(msg);
toast.error(eqUi.toastModelsFail);
} finally {
setCatalogModelsLoading(false);
}
}, [eqUi]);
const openAddHeadsetCatalog = () => {
setIsBrandDrawerOpen(true);
setBrandDrawerTab("brands");
setBrandSearchQuery("");
setCatalogSearchResults([]);
setCatalogSearchError(null);
setCatalogSearchLoading(false);
setSelectedCatalogBrand("");
setSelectedCatalogModelFromSearch("");
setSelectedCatalogModelName("");
setSelectedCatalogModelForm(undefined);
setSelectedCatalogTarget("");
setCatalogModels([]);
setCatalogModelsError(null);
void loadCatalogBrands();
};
useEffect(() => {
if (!isBrandDrawerOpen || brandDrawerTab !== "brands") return;
const timer = window.setTimeout(() => {
void searchCatalogByKeyword(brandSearchQuery);
}, 260);
return () => window.clearTimeout(timer);
}, [isBrandDrawerOpen, brandDrawerTab, brandSearchQuery, searchCatalogByKeyword]);
useEffect(() => {
if (!isBrandDrawerOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsBrandDrawerOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isBrandDrawerOpen]);
useEffect(() => {
if (!isBrandDrawerOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [isBrandDrawerOpen]);
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 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);
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[] },
) => {
if (remote.peq !== undefined) {
syncPeqCatalog({
filters: remote.filters ?? peqState?.filters ?? [],
peq: remote.peq,
peqSelect: remote.peqSelect ?? deviceState?.peqSelect ?? peqState?.peqSelect,
});
}
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(remote, deviceState?.peqSelect);
syncPeqCatalogFromState(remote, deviceState?.peqSelect);
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(deviceState?.peqSelect ?? remote.peqSelect ?? 0, 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 = async () => {
const target = peqItems[headphoneIdx];
if (!target?.name) return;
const confirmed = window.confirm(eqInterp(eqUi.deleteConfirm, { name: target.name }));
if (!confirmed) return;
try {
if (isDemoMode || !api) {
const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx);
delete peqPresetCacheRef.current[target.name];
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) });
toast.success(eqUi.toastDeleted);
return;
}
delete peqPresetCacheRef.current[target.name];
await api.removePeq([target.name]);
const latest = await api.getPeqState();
applyPeqStateToUI(latest);
toast.success(eqUi.toastDeleted);
} catch {
toast.error(eqUi.toastDeleteFail);
}
};
const openAddPresetDialog = () => {
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 (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
}
const copyPayload: PeqChangePayload = {
peqChange: {
name: nextName,
filters: bands.map(bandToPeqFilter),
autoPre: currentPeq?.autoPre ?? 0,
preamp: currentPeq?.preamp ?? 0,
canDel: currentPeq?.canDel ?? 1,
},
};
const flatPayload: PeqChangePayload = {
peqChange: {
name: nextName,
preamp: 0,
canDel: 1,
autoPre: 0,
filters: FLAT_PRESET_FILTERS,
},
};
try {
await upgradePeqChange(addPresetMode === "copy" ? copyPayload : flatPayload);
if (api && !isDemoMode) {
const latest = await api.getPeqState();
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,
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 (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
}
const bBands = cloneBands(bandsByMode.B);
const filters = bBands.map(bandToPeqFilter);
const payload: PeqChangePayload = {
peqChange: {
name: nextName,
filters,
autoPre: currentPeq?.autoPre ?? 0,
preamp: currentPeq?.preamp ?? 0,
canDel: currentPeq?.canDel ?? 1,
},
};
try {
await upgradePeqChange(payload);
if (api && !isDemoMode) {
const latest = await api.getPeqState();
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);
}
};
// msgCount 轮询更新 peqState 后,同步耳机列表与当前选中项
useEffect(() => {
if (!peqState?.peq) return;
const key = buildPeqCatalogSyncKey(peqState, deviceState?.peqSelect);
if (!key || key === lastPeqCatalogSyncKeyRef.current) return;
lastPeqCatalogSyncKeyRef.current = key;
syncPeqCatalogFromState(peqState, deviceState?.peqSelect);
}, [peqState, deviceState?.peqSelect, syncPeqCatalogFromState]);
// 切换耳机时默认回到 A 组对比
useEffect(() => {
setAbMode("A");
}, [headphoneIdx]);
// 获取耳机原始曲线
const getModelCurve = async (brand: string, name: string) => {
try {
console.log("[modelCurve] request", { brand, name });
const resp = await fetch(
`//api.luxsin.com.cn/audio/modelCurve?brand=${encodeURIComponent(brand)}&name=${encodeURIComponent(name)}`
);
const data = await resp.text();
// 使用自定义 Base64 解码
const decoded = decodeCustomBase64(data);
const parsed = JSON.parse(decoded);
console.log("[modelCurve] decoded", {
hasFrRaw:
!!(parsed &&
typeof parsed === "object" &&
"fr" in (parsed as Record<string, unknown>) &&
(parsed as { fr?: unknown }).fr &&
typeof (parsed as { fr?: unknown }).fr === "object" &&
"raw" in ((parsed as { fr?: Record<string, unknown> }).fr ?? {})),
});
console.log("[modelCurve] fr.raw", (parsed as { fr?: { raw?: unknown } }).fr?.raw);
return parsed;
} catch (error) {
console.log("[modelCurve] request failed", error);
return null;
}
};
const loadRawCurveForPeq = useCallback(
async (peq: { brand?: string; model?: string; name?: string } | undefined): Promise<number[] | null> => {
let brand = peq?.brand?.trim() ?? "";
let model = peq?.model?.trim() ?? "";
// Some presets only have `name` (e.g. "Apple AirPods Pro") and miss explicit brand/model.
if ((!brand || !model) && peq?.name) {
const [first, ...rest] = peq.name.trim().split(/\s+/);
if (!brand && first) brand = first;
if (!model && rest.length > 0) model = rest.join(" ");
}
if (!brand || !model) return null;
const modelCurve = await getModelCurve(brand, model);
if (modelCurve && typeof modelCurve === "object") {
const payload = modelCurve as {
fr?: { raw?: unknown } | unknown;
};
const rawCandidate =
payload.fr && typeof payload.fr === "object" && Array.isArray((payload.fr as { raw?: unknown }).raw)
? (payload.fr as { raw?: unknown }).raw
: null;
const raw = Array.isArray(rawCandidate)
? rawCandidate
.map((point) => {
if (typeof point === "number" && Number.isFinite(point)) return point;
if (Array.isArray(point) && point.length >= 2 && typeof point[1] === "number") return point[1];
if (point && typeof point === "object") {
const y = (point as Record<string, unknown>).y
?? (point as Record<string, unknown>).value
?? (point as Record<string, unknown>).db
?? (point as Record<string, unknown>).gain;
if (typeof y === "number" && Number.isFinite(y)) return y;
}
return NaN;
})
: null;
const cleanedRaw = raw && raw.every((v) => Number.isFinite(v)) ? (raw as number[]) : null;
console.log("[modelCurve] raw check", {
brand,
model,
hasRaw: !!cleanedRaw,
rawLength: cleanedRaw?.length ?? 0,
});
return cleanedRaw;
}
return null;
},
[],
);
// 加载耳机列表并初始化曲线
useEffect(() => {
async function loadHeadphones() {
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 api.getPeqState();
if (loadedPeq.peq && loadedPeq.peq.length > 0) {
syncPeqCatalog(loadedPeq);
setHeadphoneModels(loadedPeq.peq.map((h) => h.name));
setPeqItems(loadedPeq.peq);
const nextIdx = Math.min(
Math.max(deviceState?.peqSelect ?? loadedPeq.peqSelect ?? 0, 0),
loadedPeq.peq.length - 1,
);
setHeadphoneIdx(nextIdx);
lastPeqCatalogSyncKeyRef.current = buildPeqCatalogSyncKey(loadedPeq, deviceState?.peqSelect);
// 初始化当前选中的耳机曲线
const currentPeq = loadedPeq.peq[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;
}
}
loadHeadphones();
}, [api, isDemoMode, loadRawCurveForPeq]);
/** 切换耳机型号时变;不含 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 });
let cancelled = false;
void (async () => {
const raw = await loadRawCurveForPeq(peq as { brand?: string; model?: string });
if (cancelled) return;
setCurrentRawCurve(raw);
renderCharts(nextBands, raw, 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 } | undefined,
filtersSource: typeof DEFAULT_BANDS,
mode: "A" | "B",
delay = 320,
topLevel: PeqSubmitTopLevel = "byMode",
catalogIdx = headphoneIdx,
) => {
if (!allowPeqRemoteSyncRef.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: PeqPresetBody = {
name: peq.name,
filters,
autoPre: peq.autoPre ?? 0,
preamp: peq.preamp ?? 0,
canDel: peq.canDel ?? 1,
};
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→peqChangeB→peqApply(不因切换 A/B 误触发)
useEffect(() => {
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 chartRef = useRef<echarts.ECharts | null>(null);
const band = bands[selectedBand] ?? DEFAULT_BANDS[selectedBand] ?? DEFAULT_BANDS[0];
useEffect(() => {
setBandSliderPreview(null);
}, [selectedBand, abMode, headphoneSwitchKey]);
// Render frequency response chart using ECharts
const renderCharts = (peqFilters: typeof bands, raw: number[] | null, changeParam: boolean) => {
const list: any[] = [];
const fs = 48000;
// Calculate coefficient matrix for each filter
peqFilters.forEach((item) => {
const filterType = getFilterType(item.type);
const coeff = getSectionsMatrix(
item.gain,
item.freq,
item.q,
filterType,
false,
fs
);
if (coeff) {
list.push(coeff);
}
});
// Get frequency response data
const dataSet = visualizeResponse(list, fs);
const ops = getChartOps(dataSet, 20, -20, '#FFED00') as any;
// Get or create chart instance
const chartDom = document.getElementById('freq-chart');
if (!chartDom) return;
if (!chartRef.current) {
chartRef.current = echarts.init(chartDom);
}
const myChart = chartRef.current;
// Handle changeParam (get raw data from existing chart)
if (changeParam && myChart) {
const option = myChart.getOption() as { series?: Array<{ data?: number[] }> };
if (option.series && option.series.length > 1) {
raw = (option.series[1] as any).data;
}
}
// Clear and rebuild chart
myChart.clear();
// Add Raw and Equalized curves if raw data is available (expects same 349 points as EQ curve)
if (Array.isArray(raw) && raw.length === dataSet[1].length) {
ops.series.push({
name: 'Raw',
data: raw,
type: 'line',
showSymbol: false,
lineStyle: {
color: '#ffffff',
},
});
// Calculate equalized curve
const equalizedRaw = dataSet[1].map((value, index) => value + raw[index]);
ops.series.push({
name: 'Equalized',
data: equalizedRaw,
type: 'line',
showSymbol: false,
lineStyle: {
color: '#23d2fe',
width: 6,
opacity: 0.7,
},
});
}
myChart.setOption(ops, true);
};
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: {
name: peq.name,
filters,
autoPre: peq.autoPre ?? 0,
preamp: peq.preamp ?? 0,
canDel: peq.canDel ?? 1,
},
};
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);
if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
renderCharts(bBands, currentRawCurve, false);
}
toast.success(eqUi.toastApplyBSuccess);
} catch {
skipPeqAutoSyncRef.current = false;
toast.error(eqUi.toastApplyBFail);
}
}, [
bandsByMode.B,
currentRawCurve,
eqUi.toastApplyBFail,
eqUi.toastApplyBSuccess,
headphoneIdx,
peqItems,
upgradePeqChange,
]);
const updateBand = useCallback(
(idx: number, patch: Partial<typeof DEFAULT_BANDS[0]>) => {
setBands((prevBands) => prevBands.map((b, i) => (i === idx ? { ...b, ...patch } : b)));
},
[setBands],
);
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;
}
if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value });
else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value });
else updateBand(selectedBand, { q: parsed.value });
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);
};
return (
<div className="min-h-screen bg-black">
<div style={{
filter: eqOn ? "none" : "grayscale(1) opacity(0.4)",
transition: "filter 0.3s ease-in-out"
}}>
{/* ── Header ── */}
<div className="page-header">
<button onClick={() => setLocation("/")} className="mr-4 text-white/60 active:text-white transition-colors">
<ChevronLeft size={24} />
</button>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">HP-EQ</h1>
<IOSToggle
checked={eqOn}
onChange={(v) => {
if (v) {
void updateSetting(
bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 },
);
} else {
void updateSetting({ peqEnable: 0 });
}
}}
/>
</div>
<FeatureGate
enabled={eqOn}
className="px-4 pb-32 pt-3 space-y-3"
labels={{
title: eqUi.enableConfirmTitle ?? "HP-EQ 未开启",
description: eqUi.enableConfirmDesc ?? "当前 HP-EQ 功能已关闭,是否开启?",
cancel: eqUi.cancel ?? "取消",
confirm: eqUi.enableConfirmOk ?? "开启",
}}
onEnable={() => {
const bypass = (deviceState?.dsp_enable ?? 0) === 0;
return updateSetting(bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 });
}}
>
{/* ── Action cards: 批量编辑 / 添加耳机型号 ── */}
<div className="grid grid-cols-2 gap-3">
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={openBatchEditDialog}>
<div className="w-10 h-10 rounded-[12px] flex items-center justify-center"
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
<Edit3 size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80">{peqCardLabels.edit}</span>
</button>
<button
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
onClick={openAddHeadsetCatalog}>
<div className="w-10 h-10 rounded-[12px] flex items-center justify-center"
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
<Headphones size={20} style={{ color: "#00FFF6" }} />
</div>
<span className="text-[14px] text-white/80">{peqCardLabels.headset}</span>
</button>
</div>
{/* ── Headphone model selector ── */}
<div className="flex items-center justify-between px-1">
<div
ref={headphoneMenuRef}
className="relative z-40 flex-1 mr-2 rounded-[12px] overflow-visible backdrop-blur-md transition-all"
style={{
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
border: "1px solid rgba(0,255,246,0.52)",
boxShadow: "0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
}}
>
<button
type="button"
className="w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide text-[#E9FBFF] bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px] truncate"
onClick={() => setIsHeadphoneMenuOpen((v) => !v)}
title={headphoneModels[headphoneIdx] ?? ""}
>
{headphoneModels[headphoneIdx] ?? "—"}
</button>
<ChevronDown
size={13}
className={`absolute right-2.5 top-1/2 -translate-y-1/2 text-[#00FFF6] pointer-events-none transition-transform ${isHeadphoneMenuOpen ? "rotate-180" : ""}`}
/>
{isHeadphoneMenuOpen && (
<div
className="absolute left-0 right-0 mt-1 z-50 rounded-[10px] overflow-hidden max-h-56 overflow-y-auto"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}
>
{headphoneModels.map((model, idx) => {
const active = idx === headphoneIdx;
return (
<button
key={`${model}-${idx}`}
type="button"
className={`w-full px-3 py-2 text-left text-[13px] transition-colors truncate ${
active ? "text-black font-semibold" : "text-white/90 hover:bg-white/10"
}`}
style={active ? { background: "#00FFF6" } : undefined}
title={model}
onClick={() => handleSelectHeadphone(idx)}
>
{model}
</button>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
onClick={handleDeleteHeadphone}
>
<Minus size={15} />
</button>
<button
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
onClick={() => {
openAddPresetDialog();
}}
>
<Plus size={15} />
</button>
</div>
</div>
{/* ── Frequency response chart ── */}
<FreqChart
bands={bands}
rawCurve={currentRawCurve}
selectedBand={selectedBand}
abMode={abMode}
onAbToggle={handleAbToggle}
onCopyAndSwitchTo={handleCopyAndSwitchTo}
onApplyB={() => 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) ── */}
<div className="ios-list-group p-3">
<div className="grid grid-cols-5 gap-1.5 lg:grid-cols-10 lg:gap-1">
{bands.map((b, i) => (
<button key={i}
className={cn(
"flex flex-col items-center rounded-[10px] transition-all duration-150 active:scale-95 py-2.5 px-1 lg:py-2 lg:px-0.5",
selectedBand === i ? "text-black" : "text-white/55"
)}
style={selectedBand === i ? {
background: "#00FFF6",
boxShadow: "0 0 14px rgba(0,255,246,0.45)",
} : {
background: "rgba(44,44,46,0.65)",
}}
onClick={() => setSelectedBand(i)}>
<span
className="notranslate text-[12px] font-bold leading-tight tabular-nums lg:text-[11px]"
translate="no"
>
{freqLabel(b.freq)}
</span>
<span className="text-[9px] mt-0.5 opacity-70 leading-tight lg:text-[8px] lg:mt-0">{getFilterShortName(b.type)}</span>
</button>
))}
</div>
</div>
{/* ── Band detail card ── */}
<div className="ios-list-group p-4 space-y-5 relative z-30 overflow-visible">
{/* Filter type selector */}
<div className="flex items-center justify-between">
<span className="text-[14px] text-white/50 font-medium">{peqCardLabels.filters?.label ?? "滤波器"}</span>
<div
ref={filterMenuRef}
className="relative z-40 w-[136px] rounded-[12px] overflow-visible backdrop-blur-md transition-all"
style={{
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
border: "1px solid rgba(0,255,246,0.52)",
boxShadow: "0 0 14px rgba(0,255,246,0.12), inset 0 1px 0 rgba(255,255,255,0.08)",
}}
>
<button
type="button"
className="w-full pl-3 pr-8 py-2 text-left text-[13px] font-semibold tracking-wide text-[#E9FBFF] bg-transparent outline-none cursor-pointer active:opacity-80 transition-opacity rounded-[12px]"
onClick={() => setIsFilterMenuOpen((v) => !v)}
>
{normalizeFilterType(band.type)}
</button>
<ChevronDown
size={13}
className={`absolute right-2.5 top-1/2 -translate-y-1/2 text-[#00FFF6] pointer-events-none transition-transform ${isFilterMenuOpen ? "rotate-180" : ""}`}
/>
{isFilterMenuOpen && (
<div
className="absolute left-0 right-0 mt-1 z-50 rounded-[10px] overflow-hidden"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}
>
{FILTER_TYPES.map((type) => {
const active = normalizeFilterType(band.type) === type;
return (
<button
key={type}
type="button"
className={`w-full px-3 py-2 text-left text-[13px] transition-colors ${
active ? "text-black font-semibold" : "text-white/90 hover:bg-white/10"
}`}
style={active ? { background: "#00FFF6" } : undefined}
onClick={() => {
updateBand(selectedBand, { type });
setIsFilterMenuOpen(false);
}}
>
{type}
</button>
);
})}
</div>
)}
</div>
</div>
{/* FREQ */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium tracking-wider">FREQ</span>
<button
type="button"
onClick={() => openBandParamDialog("freq")}
className="notranslate text-[13px] font-semibold tabular-nums px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
translate="no"
>
{formatBandFreqDisplay(bandSliderPreview?.freq ?? band.freq)}
</button>
</div>
<CyanSlider
value={Math.log10(bandSliderPreview?.freq ?? band.freq)}
min={Math.log10(BAND_FREQ_MIN)}
max={Math.log10(BAND_FREQ_MAX)}
step={0.005}
onChange={(v) => {
const freq = Math.round(Math.pow(10, v));
setBandSliderPreview((prev) => ({ ...prev, freq }));
updateBand(selectedBand, { freq });
}}
/>
</div>
{/* GAIN */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium tracking-wider">GAIN</span>
<button
type="button"
onClick={() => openBandParamDialog("gain")}
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
>
{formatBandGainDisplay(bandSliderPreview?.gain ?? band.gain)}
</button>
</div>
<CyanSlider
value={bandSliderPreview?.gain ?? band.gain}
min={BAND_GAIN_MIN}
max={BAND_GAIN_MAX}
step={0.1}
onChange={(v) => {
setBandSliderPreview((prev) => ({ ...prev, gain: v }));
updateBand(selectedBand, { gain: v });
}}
/>
</div>
{/* Q 值 */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium">{peqCardLabels.q ?? "Q值"}</span>
<button
type="button"
onClick={() => openBandParamDialog("q")}
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
>
{formatBandQDisplay(bandSliderPreview?.q ?? band.q)}
</button>
</div>
<CyanSlider
value={bandSliderPreview?.q ?? band.q}
min={BAND_Q_MIN}
max={BAND_Q_MAX}
step={0.01}
onChange={(v) => {
setBandSliderPreview((prev) => ({ ...prev, q: v }));
updateBand(selectedBand, { q: v });
}}
/>
</div>
</div>
{/* ── Total gain card ── */}
<div className="ios-list-group p-4 relative z-10">
<div className="flex items-center justify-between mb-3">
<div className="flex items-baseline gap-1">
<span className="text-[13px] text-white/45">{peqCardLabels.preamp}</span>
<span className="text-[28px] font-bold ml-2" style={{ color: "#00FFF6" }}>
{preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)}
</span>
<span className="text-[15px] text-white/45 ml-0.5">dB</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] text-white/40 tracking-wider">AUTO</span>
<IOSToggle
checked={autoPreOn}
onChange={(v) => updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })}
/>
</div>
</div>
<CyanSlider
value={preampValue + 15}
min={0}
max={30}
step={0.1}
disabled={autoPreOn}
onChange={(v) => updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })}
/>
</div>
</FeatureGate>
</div>
{bandParamDialog && bandParamDialogMeta && (
<div
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4"
onClick={closeBandParamDialog}
>
<div
className="w-full max-w-[420px] rounded-[14px] p-5"
style={{
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{bandParamDialogMeta.title}</h3>
<button
type="button"
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
onClick={closeBandParamDialog}
aria-label={eqUi.closeDrawer}
>
<X size={20} />
</button>
</div>
<p className="mb-4 text-[13px] leading-relaxed text-white/45">{bandParamDialogMeta.hint}</p>
<input
value={bandParamInput}
onChange={(e) => setBandParamInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyBandParamDialog();
}
}}
inputMode={bandParamDialogMeta.inputMode}
placeholder={bandParamDialogMeta.placeholder}
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40"
autoFocus
/>
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
onClick={closeBandParamDialog}
>
{eqUi.cancel}
</button>
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
onClick={applyBandParamDialog}
>
{eqUi.confirmButton ?? eqUi.save}
</button>
</div>
</div>
</div>
)}
{isSaveBDialogOpen && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
<div
className="w-full max-w-[420px] rounded-[14px] p-5"
style={{
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{eqUi.saveBTitle}</h3>
<button
type="button"
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
onClick={() => setIsSaveBDialogOpen(false)}
aria-label={eqUi.closeDrawer}
>
<X size={20} />
</button>
</div>
<input
value={saveBPresetName}
onChange={(e) => setSaveBPresetName(e.target.value)}
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
autoFocus
/>
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
onClick={() => setIsSaveBDialogOpen(false)}
>
{eqUi.cancel}
</button>
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
onClick={() => void handleSaveBAsPreset()}
>
{eqUi.save}
</button>
</div>
</div>
</div>
)}
{isAddPresetDialogOpen && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
<div
className="w-full max-w-[420px] rounded-[14px] p-5"
style={{
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{eqUi.addPresetTitle}</h3>
<button
type="button"
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
onClick={() => setIsAddPresetDialogOpen(false)}
aria-label={eqUi.closeDrawer}
>
<X size={20} />
</button>
</div>
<div className="space-y-3">
<label
className={cn(
"flex cursor-pointer items-center gap-3 rounded-[12px] border border-white/10 p-3 transition-colors",
addPresetMode === "copy" ? "bg-white/[0.06]" : "bg-white/[0.02] hover:bg-white/[0.04]",
)}
>
<input
type="radio"
name="add-preset-mode"
checked={addPresetMode === "copy"}
onChange={() => setAddPresetMode("copy")}
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
/>
<input
value={copyPresetName}
onChange={(e) => setCopyPresetName(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
/>
</label>
<label
className={cn(
"flex cursor-pointer items-center gap-3 rounded-[12px] border border-white/10 p-3 transition-colors",
addPresetMode === "flat" ? "bg-white/[0.06]" : "bg-white/[0.02] hover:bg-white/[0.04]",
)}
>
<input
type="radio"
name="add-preset-mode"
checked={addPresetMode === "flat"}
onChange={() => setAddPresetMode("flat")}
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
/>
<input
value={flatPresetName}
onChange={(e) => setFlatPresetName(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
/>
</label>
</div>
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
onClick={() => setIsAddPresetDialogOpen(false)}
>
{eqUi.cancel}
</button>
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
onClick={handleSaveAddPreset}
>
{eqUi.save}
</button>
</div>
</div>
</div>
)}
{isBatchEditDialogOpen && (
<div className="fixed inset-0 z-[121] flex items-center justify-center bg-black/65 px-4">
<div
className="w-full max-w-[760px] rounded-[14px] p-5"
style={{
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[28px] text-white/90 font-semibold">{eqUi.batchEditTitle}</h3>
<button
type="button"
className="text-white/45 hover:text-white/75 transition-colors"
onClick={() => setIsBatchEditDialogOpen(false)}
>
<X size={20} />
</button>
</div>
<p className="mb-3 text-[13px] text-white/55">
{eqUi.batchEditHint}
</p>
<textarea
value={batchEditText}
onChange={(e) => setBatchEditText(e.target.value)}
spellCheck={false}
className="h-[360px] w-full resize-none overflow-auto rounded-[10px] border border-white/12 bg-[#15171b] p-3 font-mono text-[13px] leading-6 text-white/90 outline-none focus:border-[#00FFF6]/55"
/>
<div className="mt-5 flex items-center justify-center gap-16">
<button
type="button"
className="px-5 py-2 rounded-full text-[24px] text-white/80 bg-[#3f4349] hover:bg-[#4a4f56] transition-colors"
onClick={() => setIsBatchEditDialogOpen(false)}
>
{eqUi.cancel}
</button>
<button
type="button"
className="px-5 py-2 rounded-full text-[24px] text-black bg-[#00FFF6] hover:brightness-95 transition-all"
onClick={handleSaveBatchEdit}
>
{eqUi.save}
</button>
</div>
</div>
</div>
)}
{isBrandDrawerOpen && (
<div className="fixed inset-0 z-[110] flex flex-col justify-end">
<button
type="button"
className="absolute inset-0 bg-black/55"
aria-label={eqUi.closeDrawer}
onClick={() => setIsBrandDrawerOpen(false)}
/>
<div
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">
<div className="h-1 w-10 rounded-full bg-black/15" />
</div>
<div className="flex shrink-0 items-stretch rounded-t-[12px] bg-[#2c2c2e] px-1 pt-1">
{(["brands", "models", "target"] as const).map((tab) => {
const active = brandDrawerTab === tab;
const label =
tab === "brands" ? eqUi.tabBrands : tab === "models" ? eqUi.tabModels : eqUi.tabTarget;
return (
<button
key={tab}
type="button"
className={`flex-1 py-2.5 text-[14px] font-medium transition-colors rounded-t-[10px] ${
active ? "bg-[#1c1c1e] text-white" : "text-white/55 hover:text-white/80"
}`}
onClick={() => setBrandDrawerTab(tab)}
>
{label}
</button>
);
})}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
{brandDrawerTab === "brands" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="shrink-0 border-b border-black/8 px-3 py-2.5">
<div className="flex items-center gap-2 rounded-[10px] border border-black/12 bg-[#f5f5f7] px-3 py-2">
<Search size={18} className="shrink-0 text-black/35" />
<input
type="search"
value={brandSearchQuery}
onChange={(e) => setBrandSearchQuery(e.target.value)}
placeholder={eqUi.searchPlaceholder}
className="min-w-0 flex-1 bg-transparent text-[15px] text-black/80 outline-none placeholder:text-black/35"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
{brandSearchQuery.trim() && catalogSearchLoading && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.searching}</div>
)}
{!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogSearchError}</div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noSearchResults}</div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.map((item, idx) => (
<button
key={`${item.brandName}-${item.modelName}-${idx}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(item.brandName);
setSelectedCatalogModelFromSearch(item.modelName);
setSelectedCatalogModelName(item.modelName);
setSelectedCatalogModelForm(item.form);
setSelectedCatalogTarget("");
setCatalogModels([{ id: 0, name: item.modelName, ...(item.form ? { form: item.form } : {}) }]);
setCatalogModelsLoading(false);
setCatalogModelsError(null);
setBrandDrawerTab("target");
}}
>
<div className="text-[15px] text-black/85 font-medium">{item.modelName}</div>
<div className="mt-0.5 text-[12px] text-black/50">{item.brandName}</div>
</button>
))}
{!brandSearchQuery.trim() && catalogBrandsLoading && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogBrandsError}</div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noBrandMatch}</div>
)}
{!brandSearchQuery.trim() &&
!catalogBrandsLoading &&
!catalogBrandsError &&
filteredCatalogBrands.map((b) => (
<button
key={b.id}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(b.name);
setSelectedCatalogModelFromSearch("");
setSelectedCatalogModelName("");
setSelectedCatalogModelForm(undefined);
setSelectedCatalogTarget("");
setBrandDrawerTab("models");
void loadCatalogModels(b.name);
}}
>
{b.name}
</button>
))}
</div>
</div>
)}
{brandDrawerTab === "models" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55">
{eqUi.brandLabel}<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
{selectedCatalogModelFromSearch && (
<span className="ml-2 text-black/45">
{eqUi.modelFromSearchLabel}
{selectedCatalogModelFromSearch}
</span>
)}
</div>
{!selectedCatalogBrand && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectBrandFirst}</div>
)}
{!!selectedCatalogBrand && catalogModelsLoading && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogModelsError}</div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noModelsForBrand}</div>
)}
{!!selectedCatalogBrand &&
!catalogModelsLoading &&
!catalogModelsError &&
catalogModels.map((m) => (
<button
key={`${m.id}-${m.name}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogModelName(m.name);
setSelectedCatalogModelForm(m.form);
setSelectedCatalogTarget("");
setBrandDrawerTab("target");
}}
>
<div>{m.name}</div>
{m.form && <div className="mt-0.5 text-[12px] text-black/50">{m.form}</div>}
</button>
))}
</div>
</div>
)}
{brandDrawerTab === "target" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55 space-y-0.5">
<div>
{eqUi.targetBrand}
<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
</div>
<div>
{eqUi.targetModel}
<span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span>
</div>
<div>
{eqUi.targetForm}
<span className="font-semibold text-black/80">{selectedCatalogModelForm || eqUi.formAll}</span>
</div>
</div>
<div className="px-5 pt-4 pb-3">
<button
type="button"
disabled={!selectedCatalogTarget || isConfirmingTarget}
className="w-full h-10 rounded-full text-[24px] leading-none transition-all disabled:cursor-not-allowed flex items-center justify-center gap-3"
style={{
background: selectedCatalogTarget && !isConfirmingTarget
? "linear-gradient(180deg, #6a6a6d 0%, #565659 100%)"
: "linear-gradient(180deg, #8b8b8f 0%, #78787c 100%)",
color: selectedCatalogTarget && !isConfirmingTarget ? "#00FFF6" : "rgba(255,255,255,0.55)",
boxShadow: selectedCatalogTarget && !isConfirmingTarget
? "inset 0 1px 0 rgba(255,255,255,0.15)"
: "inset 0 1px 0 rgba(255,255,255,0.08)",
opacity: selectedCatalogTarget && !isConfirmingTarget ? 1 : 0.9,
}}
onClick={() => {
const brand = selectedCatalogBrand;
const name = selectedCatalogModelName;
const target = selectedCatalogTarget;
const form = selectedCatalogModelForm;
void (async () => {
setIsConfirmingTarget(true);
try {
const decoded = await fetchLuxsinAudioCurve(brand, name, target);
let parsed: unknown = decoded;
try {
parsed = JSON.parse(decoded);
} catch {
// Keep raw decoded text when payload isn't JSON.
}
const parsedObj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
const parametricEqRaw = parsedObj?.parametric_eq;
if (!parametricEqRaw || typeof parametricEqRaw !== "object") return;
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 postPeq: PeqChangePayload = {
peqChange: {
name: `${brand} ${name}`,
brand,
model: name,
target,
...(form ? { form } : {}),
filters,
preamp: Number(Number(parametricEq.preamp ?? 0).toFixed(2)),
autoPre: 0,
canDel: 1,
},
};
await upgradePeqChange(postPeq);
const createdName = postPeq.peqChange.name;
if (api && !isDemoMode) {
const latest = await api.getPeqState();
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
const createdIndex = latest.peq?.findIndex((item) => item.name === createdName) ?? -1;
if (createdIndex >= 0) {
setHeadphoneIdx(createdIndex);
updateSetting({ peqSelect: createdIndex });
}
} 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);
toast.success(eqUi.toastNewEqOk);
} catch {
toast.error(eqUi.toastCurveFail);
} finally {
setIsConfirmingTarget(false);
}
})();
}}
>
{isConfirmingTarget && (
<span
className="inline-block w-4 h-4 rounded-full border-2 border-white/40 border-t-[#00FFF6] animate-spin"
aria-hidden="true"
/>
)}
{isConfirmingTarget ? eqUi.confirmLoading : eqUi.confirmButton}
</button>
</div>
{!selectedCatalogModelName && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectModelFirst}</div>
)}
{!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noTargets}</div>
)}
{!!selectedCatalogModelName &&
availableCatalogTargets.map((target) => {
const active = selectedCatalogTarget === target.name;
return (
<button
key={target.name}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3 text-left active:bg-black/[0.04] transition-colors"
style={active ? { background: "rgba(0,255,246,0.14)" } : undefined}
onClick={() => {
setSelectedCatalogTarget(target.name);
toast.success(eqInterp(eqUi.toastTargetSelected, { name: target.name }));
}}
>
<div className="text-[15px] text-black/85 font-medium">{target.name}</div>
<div className="mt-1 text-[12px] text-black/55">
{eqInterp(eqUi.bassBoost, {
fc: String(target.bassBoost.fc),
q: String(target.bassBoost.q),
gain: String(target.bassBoost.gain),
})}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
)}
<BottomNav />
</div>
);
}