Initial commit: 提交控制器版本2核心代码

This commit is contained in:
yangy
2026-03-31 18:52:37 +08:00
commit 30db710093
111 changed files with 21875 additions and 0 deletions
+402
View File
@@ -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>
);
}