2142 lines
86 KiB
TypeScript
2142 lines
86 KiB
TypeScript
/* ============================================================
|
||
HP-EQ PAGE — Parametric EQ
|
||
Design: Reference luxsin_x8_hpeq_ui.jpeg
|
||
Layout (top to bottom):
|
||
1. Page header: < HP-EQ [toggle]
|
||
2. Two action cards: 批量编辑 / 添加耳机型号
|
||
3. Headphone model row: [Name ▼] [−] [+]
|
||
4. Freq response chart: legend + A/B + 复制到B + DIFF + SVG curve
|
||
5. Band grid: 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>
|
||
);
|
||
}
|