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

2142 lines
86 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: 10 pills (2 rows × 5)
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 { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
import BottomNav from "@/components/BottomNav";
import { toast } from "sonner";
import {
decodeCustomBase64,
fetchLuxsinAudioBrands,
fetchLuxsinAudioCurve,
fetchLuxsinAudioModelList,
fetchLuxsinAudioModels,
type LuxsinAudioBrand,
type LuxsinAudioModelListItem,
type LuxsinAudioModel,
type PeqFilter,
type PeqChangePayload,
} from "@/lib/luxsinApi";
import * as echarts from "echarts";
import {
getSectionsMatrix,
visualizeResponse,
getChartOps,
getFilterType,
getFilterShortName,
buildPeqSvgCurveData,
} 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";
type PeqEqUi = NonNullable<NonNullable<(typeof localeZh)["peq"]>["eqUi"]>;
function eqInterp(template: string | undefined, vars: Record<string, string | number>): string {
if (!template) return "";
let s = template;
for (const [k, v] of Object.entries(vars)) {
s = s.split(`{{${k}}}`).join(String(v));
}
return s;
}
/* ── iOS Toggle ── */
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="ios-toggle-track"><span className="ios-toggle-thumb" /></span>
</label>
);
}
/* ── Cyan Slider ── */
function CyanSlider({ value, min, max, step = 1, onChange, disabled = false }: {
value: number; min: number; max: number; step?: number; onChange: (v: number) => void; disabled?: boolean;
}) {
const fillPct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
return (
<div className={`relative flex items-center w-full mt-2 ${disabled ? "opacity-60" : ""}`}>
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
style={{
width: `${fillPct}%`,
background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6",
boxShadow: disabled ? "none" : "0 0 6px rgba(0,255,246,0.45)",
}} />
<input type="range" min={min} max={max} step={step} value={value}
className="cyan-slider relative z-10"
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))} />
</div>
);
}
/* ── Constants ── */
const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"];
function normalizeFilterType(type: string | number | undefined): string {
if (type === undefined || type === null) return "PEAK";
if (typeof type === "number") {
switch (type) {
case 0: return "LPF";
case 1: return "HPF";
case 2: return "BPF";
case 3: return "NOTCH";
case 4: return "PEAK";
case 5: return "LSHELF";
case 6: return "HSHELF";
case 7: return "APF";
default: return "PEAK";
}
}
return getFilterShortName(type);
}
/* ── Frequency Response Chart ── */
function FreqChart({
bands,
rawCurve,
selectedBand,
abMode,
onAbToggle,
onCopyMode,
onBandDrag,
onBandSelect,
eqUi,
}: {
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
rawCurve: number[] | null;
selectedBand: number;
abMode: "A" | "B";
onAbToggle: (m: "A" | "B") => void;
onCopyMode: (from: "A" | "B", to: "A" | "B") => void;
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
onBandSelect: (idx: number) => void;
eqUi: PeqEqUi;
}) {
const H = 140;
const chartContainerRef = useRef<HTMLDivElement | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const draggingBandRef = useRef<number | null>(null);
const curvePathRef = useRef<SVGPathElement | null>(null);
const rawPathRef = useRef<SVGPathElement | null>(null);
const equalizedPathRef = useRef<SVGPathElement | null>(null);
const [chartWidth, setChartWidth] = useState(340);
const W = chartWidth;
const [isBandDragging, setIsBandDragging] = useState(false);
useEffect(() => {
const node = chartContainerRef.current;
if (!node) return;
const updateWidth = () => {
const nextWidth = Math.round(node.clientWidth);
if (nextWidth > 0) setChartWidth(nextWidth);
};
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(node);
return () => observer.disconnect();
}, []);
const Y_DB_MAX = 20;
const freqToX = (f: number) => {
const logMin = Math.log10(20), logMax = Math.log10(20000);
return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W;
};
const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10);
const xToFreq = (x: number) => {
const logMin = Math.log10(20), logMax = Math.log10(20000);
const clampedX = Math.max(0, Math.min(W, x));
return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin));
};
const yToGain = (y: number) => {
const clampedY = Math.max(10, Math.min(H - 10, y));
return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX;
};
const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => {
const svg = svgRef.current;
if (!svg || W <= 0) return;
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const x = ((clientX - rect.left) / rect.width) * W;
const y = ((clientY - rect.top) / rect.height) * H;
const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x))));
const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1));
onBandDrag(idx, { freq, gain });
};
const handleBandPointerDown = (idx: number, e: React.PointerEvent<SVGGElement>) => {
e.preventDefault();
e.stopPropagation();
draggingBandRef.current = idx;
setIsBandDragging(true);
onBandSelect(idx);
updateBandFromPointer(idx, e.clientX, e.clientY);
};
const handleSvgPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
const idx = draggingBandRef.current;
if (idx === null) return;
e.preventDefault();
updateBandFromPointer(idx, e.clientX, e.clientY);
};
const stopDragging = () => {
draggingBandRef.current = null;
setIsBandDragging(false);
};
const curveData = useMemo(
() =>
buildPeqSvgCurveData({
bands,
width: W,
height: H,
fs: 48000,
yDbMax: Y_DB_MAX,
minFreq: 20,
maxFreq: 20000,
paddingY: 10,
}),
[bands, W],
);
const pathD = curveData.pathD;
const fillD = curveData.fillD;
const [curveVisibilityByMode, setCurveVisibilityByMode] = useState<
Record<"A" | "B", { eq: boolean; raw: boolean; equalized: boolean }>
>({
A: { eq: true, raw: true, equalized: true },
B: { eq: true, raw: false, equalized: false },
});
const showEq = curveVisibilityByMode[abMode].eq;
const showRaw = curveVisibilityByMode[abMode].raw;
const showEqualized = curveVisibilityByMode[abMode].equalized;
const toggleCurveVisibility = (key: "eq" | "raw" | "equalized") => {
setCurveVisibilityByMode((prev) => ({
...prev,
[abMode]: {
...prev[abMode],
[key]: !prev[abMode][key],
},
}));
};
const rawPathD = useMemo(() => {
if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
return curveData.points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${gainToY(rawCurve[i] ?? 0).toFixed(1)}`)
.join(" ");
}, [rawCurve, curveData.points]);
const equalizedPathD = useMemo(() => {
if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
return curveData.points
.map((p, i) => {
const y = gainToY((p.gainDb ?? 0) + (rawCurve[i] ?? 0));
return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(" ");
}, [rawCurve, curveData.points]);
const hasRawCurve = !!rawPathD;
const hasEqualizedCurve = !!equalizedPathD;
// Reusable left-to-right stroke draw animation (skip while dragging a band).
useStrokeDrawAnimation(curvePathRef, pathD, {
enabled: !isBandDragging,
durationMs: 1350,
});
useStrokeDrawAnimation(rawPathRef, rawPathD, {
enabled: !isBandDragging && !!rawPathD,
durationMs: 1350,
});
useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, {
enabled: !isBandDragging && !!equalizedPathD,
durationMs: 1350,
});
const targetMode: "A" | "B" = abMode === "A" ? "B" : "A";
const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
return (
<div className="ios-list-group p-3 mb-3">
{/* Legend row */}
<div className="flex items-center gap-3 mb-2 px-1">
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity"
onClick={() => toggleCurveVisibility("eq")}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#FFED00" }} />
<span className="text-[10px]" style={{ color: "#FFED00", opacity: showEq ? 1 : 0.35 }}>Equalizer</span>
</button>
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => toggleCurveVisibility("raw")}
disabled={!hasRawCurve}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#ffffff" }} />
<span className="text-[10px]" style={{ color: "#ffffff", opacity: showRaw ? 1 : 0.35 }}>Raw</span>
</button>
<button
type="button"
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => toggleCurveVisibility("equalized")}
disabled={!hasEqualizedCurve}
>
<div className="w-3 h-[2px] rounded" style={{ background: "#23d2fe" }} />
<span className="text-[10px]" style={{ color: "#23d2fe", opacity: showEqualized ? 1 : 0.35 }}>Equalized</span>
</button>
</div>
{/* A/B + DIFF controls */}
<div className="flex items-center gap-2 mb-2 px-1">
{/* A/B toggle pill */}
<div className="flex items-center rounded-[8px] overflow-hidden"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}>
{(["A", "B"] as const).map((m) => (
<button key={m}
className="px-3 py-1 text-[12px] font-semibold transition-all duration-150"
style={abMode === m ? {
background: "#00FFF6", color: "#000", borderRadius: 6,
} : { color: "rgba(255,255,255,0.4)" }}
onClick={() => onAbToggle(m)}>
{m}
</button>
))}
</div>
<button
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
onClick={() => {
onCopyMode(abMode, targetMode);
setCurveVisibilityByMode((prev) => ({
...prev,
[targetMode]: { ...prev[abMode] },
}));
onAbToggle(targetMode);
toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
}}>
{copyButtonText}
</button>
<button
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
onClick={() => toast.info(eqUi.toastChartLoadedDevice)}
>
{eqUi.chartLoadToDevice}
</button>
</div>
{/* SVG chart */}
<div
ref={chartContainerRef}
className="rounded-[10px] overflow-hidden"
style={{ background: "rgba(10,12,10,0.7)" }}
>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ height: 140, touchAction: "none" }}
onPointerMove={handleSvgPointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onPointerLeave={stopDragging}
>
{/* dB grid */}
{gainLabels.map((g) => (
<g key={g}>
<line x1="0" y1={gainToY(g)} x2={W} y2={gainToY(g)}
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
<text x="3" y={gainToY(g) - 2} fontSize="7" fill="rgba(255,255,255,0.2)">
{g > 0 ? `+${g}` : g}
</text>
</g>
))}
{/* Freq grid */}
{freqLabels.map((f) => (
<line key={f} x1={freqToX(f)} y1="0" x2={freqToX(f)} y2={H - 12}
stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
))}
{/* Zero line */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.15)" strokeWidth="1" />
{/* Fill */}
{showEq && <path d={fillD} fill="rgba(255, 237, 0, 0.08)" />}
{/* EQ curve */}
{showEq && (
<path
ref={curvePathRef}
d={pathD}
fill="none"
stroke="#FFED00"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Raw */}
{rawPathD && showRaw && (
<path
ref={rawPathRef}
d={rawPathD}
fill="none"
stroke="#ffffff"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.9"
/>
)}
{/* Equalized = EQ + Raw */}
{equalizedPathD && showEqualized && (
<path
ref={equalizedPathRef}
d={equalizedPathD}
fill="none"
stroke="#23d2fe"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.95"
/>
)}
{/* Band nodes with index */}
{showEq && bands.map((band, i) => (
<g
key={i}
style={{ cursor: "grab" }}
onPointerDown={(e) => handleBandPointerDown(i, e)}
>
{(() => {
const isSelected = i === selectedBand;
const outerR = isSelected ? 8.8 : 7;
const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)";
const strokeColor = isSelected ? "#FFED00" : "#FFED00";
const textColor = isSelected ? "#000000" : "#FFED00";
return (
<>
<circle
cx={freqToX(band.freq)}
cy={gainToY(band.gain)}
r="14"
fill="transparent"
/>
<circle
cx={freqToX(band.freq)}
cy={gainToY(band.gain)}
r={outerR}
fill={fillColor}
stroke={strokeColor}
strokeWidth={isSelected ? 1.8 : 1.5}
/>
<text
x={freqToX(band.freq)}
y={gainToY(band.gain) + 3.5}
textAnchor="middle"
fontSize="7"
fill={textColor}
fontWeight="bold"
pointerEvents="none"
>
{i + 1}
</text>
</>
);
})()}
</g>
))}
{/* Freq axis labels */}
{freqLabels.map((f) => (
<text key={f} x={freqToX(f)} y={H - 1} textAnchor="middle"
fontSize="7" fill="rgba(255,255,255,0.25)">
{f >= 1000 ? `${f / 1000}k` : f}
</text>
))}
</svg>
</div>
</div>
);
}
/* ── Default bands matching reference image ── */
const DEFAULT_BANDS = [
{ freq: 9500, gain: 0, q: 1.41, type: "LSHELF", enabled: true },
{ freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true },
{ freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true },
{ freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true },
{ freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true },
{ freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true },
{ freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true },
{ freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true },
{ freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true },
{ freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true },
];
function cloneBands(
source: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>,
) {
return source.map((band) => ({ ...band }));
}
function freqLabel(f: number) {
return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`;
}
function getUniquePresetName(base: string, existingNames: string[]) {
if (!existingNames.includes(base)) return base;
let index = 1;
while (existingNames.includes(`${base}_${index}`)) {
index += 1;
}
return `${base}_${index}`;
}
const FLAT_PRESET_FILTERS: PeqFilter[] = [
{ type: 4, fc: 80, gain: 0, q: 0.1 },
{ type: 4, fc: 150, gain: 0, q: 0.1 },
{ type: 4, fc: 350, gain: 0, q: 0.1 },
{ type: 4, fc: 750, gain: 0, q: 0.1 },
{ type: 4, fc: 1500, gain: 0, q: 0.1 },
{ type: 4, fc: 3000, gain: 0, q: 0.1 },
{ type: 4, fc: 6000, gain: 0, q: 0.1 },
{ type: 4, fc: 10000, gain: 0, q: 0.1 },
{ type: 4, fc: 14000, gain: 0, q: 0.1 },
{ type: 4, fc: 18000, gain: 0, q: 0.1 },
];
type CatalogTarget = {
name: string;
bassBoost: { fc: number; q: number; gain: number };
ear: "in" | "over" | "all";
};
const CATALOG_TARGETS: CatalogTarget[] = [
{ name: "Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
{ name: "HMS II.3 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
{ name: "crinacle EARS + 711 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
{ name: "Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
{ name: "AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" },
{ name: "HMS II.3 AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" },
{ name: "HMS II.3 Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
{ name: "Diffuse Field 5128 (-1 dB/oct)", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "over" },
{ name: "LMG 5128 0.6 without bass", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
{ name: "JM-1 with Harman filters", bassBoost: { fc: 105, q: 0.7, gain: 6.5 }, ear: "all" },
{ name: "oratory1990 in-ear", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
{ name: "oratory1990 over-ear", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
{ name: "Harman over-ear 2013", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
{ name: "Flat", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "all" },
];
/** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */
function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: string | number }): PeqFilter {
return {
fc: b.freq,
gain: b.gain,
q: b.q,
type: getFilterType(b.type),
};
}
/* ── Main Component ── */
export default function EQPage() {
const [, setLocation] = useLocation();
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange } = useDevice();
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
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 [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 syncingHeadphoneRef = useRef(false);
const peqSyncTimerRef = useRef<number | null>(null);
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 preampValue = Number(currentPeq?.preamp ?? 0);
const autoPreOn = (currentPeq?.autoPre ?? 0) === 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 freq = f.fc || f.freq || f.frequency || 1000;
return {
freq: Number(freq),
gain: Number(f.gain || 0),
q: Number(f.q || 1),
type: normalizeFilterType(f.type),
enabled: f.enabled !== undefined ? f.enabled : true,
};
});
};
const toCompactNumber = (value: number) => Number(value.toFixed(3)).toString();
const buildBatchEditText = useCallback(() => {
const preamp = 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 merged = {
...item,
autoPre: 0,
preamp: parsed.preamp,
filters: parsed.filters.map(bandToPeqFilter),
};
updatedPeq = merged;
return merged;
})
);
setBands(parsed.filters);
schedulePeqSync(updatedPeq, parsed.filters, 0);
setSelectedBand(0);
setIsBatchEditDialogOpen(false);
toast.success(eqUi.toastBatchApplied);
} catch (error) {
toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError);
}
};
const applyPeqStateToUI = (
peqState: { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number },
) => {
const items = peqState.peq ?? [];
setPeqItems(items as typeof peqItems);
setHeadphoneModels(items.map((item) => item.name));
if (items.length === 0) {
setHeadphoneIdx(0);
setBandsForBothModes(DEFAULT_BANDS);
setSelectedBandByMode({ A: 0, B: 0 });
return;
}
const nextIdx = Math.min(Math.max(peqState.peqSelect ?? 0, 0), items.length - 1);
setHeadphoneIdx(nextIdx);
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);
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) });
toast.success(eqUi.toastDeleted);
return;
}
await api.removePeq([target.name]);
const latest = await api.getPeqState();
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
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 handleSaveAddPreset = async () => {
const nextName = addPresetMode === "copy" ? copyPresetName.trim() : flatPresetName.trim();
if (!nextName) {
toast.error("名称不能为空");
return;
}
if (headphoneModels.includes(nextName)) {
toast.error("名称已存在,请修改后再保存");
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("已新增预设");
} catch {
toast.error("新增预设失败");
}
};
// 同步 peqSelect 变化
useEffect(() => {
if (deviceState?.peqSelect !== undefined) {
setHeadphoneIdx(deviceState.peqSelect);
}
}, [deviceState?.peqSelect]);
// 获取耳机原始曲线
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 peqState = await api.getPeqState();
if (peqState.peq && peqState.peq.length > 0) {
setHeadphoneModels(peqState.peq.map(h => h.name));
setPeqItems(peqState.peq);
// 初始化当前选中的耳机曲线
const currentPeq = peqState.peq[peqState.peqSelect || 0];
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 刷新 10 个滤波器与曲线(暂停一次上报避免错写)
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;
};
}, [headphoneIdx, peqItems, loadRawCurveForPeq]);
const schedulePeqSync = (
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
filtersSource: typeof bands,
delay = 320
) => {
if (!allowPeqRemoteSyncRef.current) return;
if (!isDemoMode && !api) return;
if (!peq?.name) return;
if (peqSyncTimerRef.current !== null) {
window.clearTimeout(peqSyncTimerRef.current);
}
peqSyncTimerRef.current = window.setTimeout(() => {
const filters = filtersSource.map(bandToPeqFilter);
const payload: PeqChangePayload = {
peqChange: {
name: peq.name,
filters,
autoPre: peq.autoPre ?? 0,
preamp: peq.preamp ?? 0,
canDel: peq.canDel ?? 1,
},
};
upgradePeqChange(payload).catch((err) => {
toast.error("EQ 保存失败");
});
}, delay);
};
// 参数变更(含拖动曲线)→ POST peqChange(防抖)
useEffect(() => {
if (syncingHeadphoneRef.current) return;
schedulePeqSync(peqItems[headphoneIdx], bands, 320);
return () => {
if (peqSyncTimerRef.current !== null) {
window.clearTimeout(peqSyncTimerRef.current);
}
};
}, [bands, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange]);
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];
// 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 updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
const nextBands = bands.map((b, i) => (i === idx ? { ...b, ...patch } : b));
setBands(nextBands);
// Re-render chart when band changes
if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
renderCharts(nextBands, currentRawCurve, false);
}
};
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;
return merged;
});
return next;
});
schedulePeqSync(updatedPeq, bands, 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) => updateSetting({ peqEnable: v ? 1 : 0 })} />
</div>
<div className="px-4 pb-32 pt-3 space-y-3">
{/* ── 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={() => {
setHeadphoneIdx(idx);
updateSetting({ peqSelect: idx });
setIsHeadphoneMenuOpen(false);
}}
>
{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={setAbMode}
onCopyMode={copyModeParams}
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
onBandSelect={setSelectedBand}
eqUi={eqUi}
/>
{/* ── Band grid (2 rows × 5) ── */}
<div className="ios-list-group p-3">
<div className="grid grid-cols-5 gap-1.5">
{bands.map((b, i) => (
<button key={i}
className={cn(
"flex flex-col items-center py-2.5 px-1 rounded-[10px] transition-all duration-150 active:scale-95",
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="text-[12px] font-bold leading-tight">{freqLabel(b.freq)}</span>
<span className="text-[9px] mt-0.5 opacity-70 leading-tight">{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>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
{band.freq >= 1000 ? `${(band.freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` : `${band.freq} Hz`}
</span>
</div>
<CyanSlider value={Math.log10(band.freq)} min={Math.log10(20)} max={Math.log10(20000)} step={0.005}
onChange={(v) => updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} />
</div>
{/* GAIN */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium tracking-wider">GAIN</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
{band.gain >= 0 ? `+${band.gain.toFixed(1)}` : band.gain.toFixed(1)} dB
</span>
</div>
<CyanSlider value={band.gain} min={-15} max={15} step={0.1}
onChange={(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>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
{band.q.toFixed(2)}
</span>
</div>
<CyanSlider value={band.q} min={0.1} max={10} step={0.01}
onChange={(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>
</div>
</div>
{isAddPresetDialogOpen && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/60 px-4">
<div
className="w-full max-w-[420px] rounded-[14px] p-5"
style={{
background: "rgba(236,236,238,0.97)",
border: "1px solid rgba(255,255,255,0.35)",
}}
>
<div className="flex items-center justify-between mb-5">
<h3 className="text-[31px] text-black/75 font-semibold">新增预设</h3>
<button
type="button"
className="text-black/45 hover:text-black/70 transition-colors"
onClick={() => setIsAddPresetDialogOpen(false)}
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<label className="flex items-center gap-3">
<input
type="radio"
checked={addPresetMode === "copy"}
onChange={() => setAddPresetMode("copy")}
className="w-4 h-4"
/>
<input
value={copyPresetName}
onChange={(e) => setCopyPresetName(e.target.value)}
className="flex-1 h-10 px-3 rounded-[5px] text-[15px] text-black/70 bg-[#f0f0f2] border border-black/15 outline-none"
/>
</label>
<label className="flex items-center gap-3">
<input
type="radio"
checked={addPresetMode === "flat"}
onChange={() => setAddPresetMode("flat")}
className="w-4 h-4"
/>
<input
value={flatPresetName}
onChange={(e) => setFlatPresetName(e.target.value)}
className="flex-1 h-10 px-3 rounded-[5px] text-[15px] text-black/70 bg-[#f0f0f2] border border-black/15 outline-none"
/>
</label>
</div>
<div className="mt-6 flex items-center justify-center gap-16">
<button
type="button"
className="px-5 py-2 rounded-full text-[26px] text-black/80 bg-[#d2d2d5] hover:bg-[#c7c7ca] transition-colors"
onClick={() => setIsAddPresetDialogOpen(false)}
>
取消
</button>
<button
type="button"
className="px-5 py-2 rounded-full text-[26px] text-black bg-[#00FFF6] hover:brightness-95 transition-all"
onClick={handleSaveAddPreset}
>
保存
</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>
);
}