Initial commit: 提交控制器版本2核心代码
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
/* ============================================================
|
||||
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 } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useState, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import { toast } from "sonner";
|
||||
import { navigateToSelect } from "./SelectPage";
|
||||
|
||||
/* ── 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 }: {
|
||||
value: number; min: number; max: number; step?: number; onChange: (v: number) => void;
|
||||
}) {
|
||||
const fillPct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
|
||||
return (
|
||||
<div className="relative flex items-center w-full mt-2">
|
||||
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
||||
style={{ width: `${fillPct}%`, background: "#00FFF6", boxShadow: "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"
|
||||
onChange={(e) => onChange(Number(e.target.value))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Constants ── */
|
||||
const FILTER_TYPES = ["PEAK", "LSHELF", "HSHELF", "NOTCH", "LPF", "HPF"];
|
||||
const HEADPHONE_MODELS = [
|
||||
"Sennheiser HD 650",
|
||||
"Sennheiser HD 800",
|
||||
"Sony WH-1000XM5",
|
||||
"AKG K701",
|
||||
"Beyerdynamic DT 990",
|
||||
"Audio-Technica ATH-M50x",
|
||||
];
|
||||
|
||||
/* ── Frequency Response Chart ── */
|
||||
function FreqChart({
|
||||
bands, abMode, onAbToggle,
|
||||
}: {
|
||||
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
|
||||
abMode: "A" | "B";
|
||||
onAbToggle: (m: "A" | "B") => void;
|
||||
}) {
|
||||
const W = 340, H = 140;
|
||||
|
||||
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 / 15) * (H / 2 - 10);
|
||||
|
||||
const curve = useMemo(() => {
|
||||
const freqs = Array.from({ length: 120 }, (_, i) => 20 * Math.pow(1000, i / 119));
|
||||
return freqs.map((f) => {
|
||||
let g = 0;
|
||||
bands.forEach((b) => {
|
||||
if (!b.enabled) return;
|
||||
const ratio = f / b.freq;
|
||||
const lr = Math.log10(ratio);
|
||||
g += b.gain * Math.exp(-(lr * lr) * b.q * 2);
|
||||
});
|
||||
return { x: freqToX(f), y: gainToY(g), g };
|
||||
});
|
||||
}, [bands]);
|
||||
|
||||
const pathD = curve.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ");
|
||||
const fillD = `${pathD} L ${W} ${H / 2} L 0 ${H / 2} Z`;
|
||||
|
||||
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
|
||||
const gainLabels = [15, 9, 3, 0, -3, -9, -15];
|
||||
|
||||
return (
|
||||
<div className="ios-list-group p-3 mb-3">
|
||||
{/* Legend row */}
|
||||
<div className="flex items-center gap-3 mb-2 px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-[2px] rounded" style={{ background: "#f59e0b" }} />
|
||||
<span className="text-[10px] text-white/45">Equalizer</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-[2px] rounded bg-white/25" />
|
||||
<span className="text-[10px] text-white/45">Raw</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-[2px] rounded" style={{ background: "#00FFF6" }} />
|
||||
<span className="text-[10px] text-white/45">Equalized</span>
|
||||
</div>
|
||||
</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={() => toast.info("已复制到 B")}>
|
||||
复制到 B
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
className="px-2.5 py-1 rounded-[6px] text-[11px] font-bold"
|
||||
style={{ background: "rgba(245,158,11,0.2)", border: "1px solid rgba(245,158,11,0.4)", color: "#f59e0b" }}>
|
||||
DIFF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SVG chart */}
|
||||
<div className="rounded-[10px] overflow-hidden" style={{ background: "rgba(10,12,10,0.7)" }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ height: 140 }}>
|
||||
{/* 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 */}
|
||||
<path d={fillD} fill="rgba(0,255,246,0.05)" />
|
||||
{/* Equalized curve */}
|
||||
<path d={pathD} fill="none" stroke="#00FFF6" strokeWidth="2" />
|
||||
{/* Raw curve (flat) */}
|
||||
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.2)" strokeWidth="1.5" strokeDasharray="4,3" />
|
||||
{/* Band nodes with index */}
|
||||
{bands.map((band, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={freqToX(band.freq)} cy={gainToY(band.gain)} r="7"
|
||||
fill="rgba(0,0,0,0.5)" stroke="#00FFF6" strokeWidth="1.5" />
|
||||
<text x={freqToX(band.freq)} y={gainToY(band.gain) + 3.5}
|
||||
textAnchor="middle" fontSize="7" fill="#00FFF6" fontWeight="bold">
|
||||
{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 freqLabel(f: number) {
|
||||
return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`;
|
||||
}
|
||||
|
||||
/* ── Main Component ── */
|
||||
export default function EQPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { deviceState, updateSetting } = useDevice();
|
||||
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
|
||||
|
||||
const [bands, setBands] = useState(DEFAULT_BANDS);
|
||||
const [selectedBand, setSelectedBand] = useState(3);
|
||||
const [headphoneIdx, setHeadphoneIdx] = useState(0);
|
||||
const [abMode, setAbMode] = useState<"A" | "B">("A");
|
||||
const [autoGain, setAutoGain] = useState(false);
|
||||
|
||||
const band = bands[selectedBand];
|
||||
|
||||
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
|
||||
setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b));
|
||||
};
|
||||
|
||||
const totalGain = bands.reduce((sum, b) => sum + (b.enabled ? b.gain : 0), 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={() => toast.info("批量编辑功能即将推出")}>
|
||||
<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">批量编辑</span>
|
||||
</button>
|
||||
<button
|
||||
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
|
||||
onClick={() => toast.info("添加耳机型号功能即将推出")}>
|
||||
<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">添加耳机型号</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Headphone model selector ── */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<button
|
||||
className="flex items-center gap-2 active:opacity-70 transition-opacity"
|
||||
onClick={() => {
|
||||
navigateToSelect("选择耳机型号", HEADPHONE_MODELS, headphoneIdx, "/eq", "headphone");
|
||||
setLocation("/select");
|
||||
}}>
|
||||
<span className="text-[20px] font-bold text-white">{HEADPHONE_MODELS[headphoneIdx]}</span>
|
||||
<ChevronDown size={18} className="text-white/50" />
|
||||
</button>
|
||||
<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={() => setHeadphoneIdx((i) => Math.max(0, i - 1))}>
|
||||
<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={() => setHeadphoneIdx((i) => Math.min(HEADPHONE_MODELS.length - 1, i + 1))}>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Frequency response chart ── */}
|
||||
<FreqChart bands={bands} abMode={abMode} onAbToggle={setAbMode} />
|
||||
|
||||
{/* ── 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">{b.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Band detail card ── */}
|
||||
<div className="ios-list-group p-4 space-y-5">
|
||||
{/* Filter type selector → goes to select page */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[14px] text-white/50 font-medium">滤波器</span>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] text-[13px] font-semibold text-white active:opacity-70 transition-opacity"
|
||||
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}
|
||||
onClick={() => {
|
||||
navigateToSelect("选择滤波器类型", FILTER_TYPES, FILTER_TYPES.indexOf(band.type), "/eq", `filter_${selectedBand}`);
|
||||
setLocation("/select");
|
||||
}}>
|
||||
{band.type}
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</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">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">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[13px] text-white/45">总增益</span>
|
||||
<span className="text-[28px] font-bold ml-2" style={{ color: "#00FFF6" }}>
|
||||
{totalGain >= 0 ? `+${totalGain.toFixed(1)}` : totalGain.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={autoGain} onChange={setAutoGain} />
|
||||
</div>
|
||||
</div>
|
||||
<CyanSlider value={totalGain + 15} min={0} max={30} step={0.1} onChange={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user