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
+319
View File
@@ -0,0 +1,319 @@
/* ============================================================
HOME — Luxsin X9 Controller (Main Dashboard)
Design: Reference luxsin_x8_首页.png
Layout (top to bottom):
1. TopBar: device name + settings icon
2. Device status card (运行中/待机/充电 + 电量/模式/环保参数)
3. Device render image (hardware photo)
4. Volume slider with dB display
5. 4 quick-action circle buttons (电源/EQ/HP-EQ/Bypass)
6. iOS list rows (输入源/输出端口/Effect/音频设置/VU表)
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import ConnectScreen from "@/components/ConnectScreen";
import BottomNav from "@/components/BottomNav";
import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi";
import { cn } from "@/lib/utils";
import {
ChevronRight,
Power,
Settings,
Sliders,
Activity,
Gauge,
ChevronDown,
Zap,
Wifi,
} from "lucide-react";
import { useLocation } from "wouter";
import { useRef, useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
// ── Volume in dB (0-200 → -100dB to 0dB) ──
function volToDB(v: number) {
if (v === 0) return "-∞";
const db = (v - 200) / 2;
return `${db >= 0 ? "+" : ""}${db.toFixed(1)}dB`;
}
// ── iOS-style 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>
);
}
// ── List row ──
function ListRow({
icon, label, value, onClick, toggle, checked, onToggle,
}: {
icon: React.ReactNode;
label: string;
value?: string;
onClick?: () => void;
toggle?: boolean;
checked?: boolean;
onToggle?: (v: boolean) => void;
}) {
return (
<div className="ios-list-row cursor-pointer" onClick={onClick}>
<div className="w-8 h-8 rounded-[8px] flex items-center justify-center mr-3 flex-shrink-0"
style={{ background: "rgba(0,255,246,0.1)", border: "1px solid rgba(0,255,246,0.15)" }}>
<span className="text-[#00FFF6]">{icon}</span>
</div>
<div className="flex-1 min-w-0">
<span className="text-[16px] text-white font-medium">{label}</span>
</div>
{toggle ? (
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
) : (
<div className="flex items-center gap-1">
{value && <span className="ios-row-value">{value}</span>}
<ChevronRight size={16} className="ios-chevron" />
</div>
)}
</div>
);
}
export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting } = useDevice();
const [, setLocation] = useLocation();
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false);
useEffect(() => {
if (!isDragging.current && deviceState?.volume !== undefined) {
setLocalVol(deviceState.volume);
}
}, [deviceState?.volume]);
const handleVolChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setLocalVol(v);
setVolume(v);
}, [setVolume]);
if (!isConnected) return <ConnectScreen />;
const ds = deviceState!;
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
const outputLabel = OUTPUT_LABELS[ds.output] ?? "耳机";
const effectOn = ds.audio_enable === 1;
const audioOn = ds.audio_enable === 1;
const vuLabel = `VU表${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
// Slider fill %
const fillPct = localVol;
return (
<div className="min-h-screen bg-black flex flex-col">
{/* ── Top bar ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<button className="flex items-center gap-1.5 text-white">
<span className="text-[18px] font-bold tracking-tight">Luxsin X9</span>
<ChevronDown size={16} className="text-white/50" />
</button>
<button onClick={() => setLocation("/system")}
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.6)" }}>
<Settings size={18} />
</button>
</div>
{/* ── Device render image ── */}
<div className="px-4 pb-4">
<div className="w-full rounded-2xl overflow-hidden flex items-center justify-center"
style={{ background: "rgba(18,18,20,0.8)", minHeight: 140 }}>
{/* Stylized device illustration */}
<div className="w-full relative" style={{ height: 140 }}>
{/* Device body */}
<div className="absolute inset-x-4 inset-y-3 rounded-xl flex items-center"
style={{ background: "linear-gradient(135deg, #1a1a1c 0%, #2a2a2e 50%, #1a1a1c 100%)", border: "1px solid rgba(255,255,255,0.08)" }}>
{/* Left ports */}
<div className="flex flex-col gap-2 px-4">
<div className="w-6 h-6 rounded-full border-2 border-white/20 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white/30" />
</div>
<div className="w-5 h-5 rounded-full border-2 border-amber-400/40" />
</div>
{/* VU display area */}
<div className="flex-1 mx-3 rounded-lg overflow-hidden"
style={{ background: "#0d1208", border: "1px solid rgba(255,255,255,0.1)", height: 90 }}>
<div className="flex items-center justify-between px-2 pt-1">
<span className="text-[9px] text-white/50">{ds.audioFormat}</span>
<span className="text-[9px] text-white/50">{volToDB(localVol)}</span>
</div>
{/* Mini VU bars */}
<div className="flex gap-0.5 px-2 pt-1 pb-2 h-16 items-end">
{Array.from({ length: 20 }).map((_, i) => {
const h = Math.max(0.1, Math.sin((i / 20) * Math.PI) * 0.8 + Math.random() * 0.2);
const isHot = i > 16;
return (
<div key={i} className="flex-1 rounded-sm"
style={{
height: `${h * 100}%`,
background: isHot ? "#ef4444" : i > 13 ? "#f59e0b" : "#00FFF6",
opacity: 0.7,
}} />
);
})}
</div>
</div>
{/* Right knob */}
<div className="pr-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center"
style={{ background: "radial-gradient(circle at 35% 35%, #4a4a4e, #1a1a1c)", border: "2px solid rgba(255,255,255,0.12)", boxShadow: "inset 0 2px 4px rgba(0,0,0,0.5)" }}>
<div className="w-1 h-4 rounded-full bg-white/40" style={{ transform: "rotate(-30deg)", transformOrigin: "bottom center" }} />
</div>
</div>
</div>
{/* Brand label */}
<div className="absolute top-5 left-8">
<span className="text-[11px] font-bold tracking-widest text-white/30">LUXSIN</span>
</div>
</div>
</div>
</div>
{/* ── Volume slider ── */}
<div className="px-4 mb-5">
<div className="flex items-center gap-3">
<button onClick={() => { setLocalVol(0); setVolume(0); }}
className="text-white/50 active:text-white transition-colors flex-shrink-0">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
<div className="flex-1 relative flex items-center">
{/* Track fill */}
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
style={{ width: `${fillPct}%`, background: "#00FFF6", boxShadow: "0 0 8px rgba(0,255,246,0.5)" }} />
<input
type="range" min={0} max={200} value={localVol}
className="cyan-slider relative z-10"
onMouseDown={() => { isDragging.current = true; }}
onMouseUp={() => { isDragging.current = false; }}
onTouchStart={() => { isDragging.current = true; }}
onTouchEnd={() => { isDragging.current = false; }}
onChange={handleVolChange}
/>
</div>
<button onClick={() => { setLocalVol(200); setVolume(200); }}
className="text-white/50 active:text-white transition-colors flex-shrink-0">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
</button>
</div>
{/* dB value */}
<div className="text-center mt-1">
<span className="text-[22px] font-bold tracking-tight" style={{ color: "#00FFF6" }}>
{volToDB(localVol)}
</span>
</div>
</div>
{/* ── 4 quick-action circle buttons ── */}
<div className="flex justify-around px-6 mb-6">
{/* Power */}
<div className="flex flex-col items-center gap-2">
<button
className="icon-circle-btn"
onClick={() => toast.info("远程待机功能即将推出")}
>
<Power size={22} className="text-white/60" />
</button>
<span className="text-[11px] text-white/35"></span>
</div>
{/* EQ */}
<div className="flex flex-col items-center gap-2">
<button
className={cn("icon-circle-btn", !bypassOn && ds.peqEnable === 1 && "active")}
onClick={() => setLocation("/eq")}
>
<Sliders size={22} className={(!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
<span className={cn("text-[11px]", (!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/35")}>HP-EQ</span>
</div>
{/* Effect */}
<div className="flex flex-col items-center gap-2">
<button
className={cn("icon-circle-btn", !bypassOn && effectOn && "active")}
onClick={() => setLocation("/effects")}
>
<Activity size={22} className={(!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/60"} />
</button>
<span className={cn("text-[11px]", (!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/35")}>Effect</span>
</div>
{/* Bypass (DSP) */}
<div className="flex flex-col items-center gap-2">
<button
className={cn("icon-circle-btn", bypassOn && "active")}
onClick={() => updateSetting({ dsp_enable: bypassOn ? 1 : 0 })}
>
<Zap size={22} className={bypassOn ? "text-[#00FFF6]" : "text-white/60"} />
</button>
<span className={cn("text-[11px]", bypassOn ? "text-[#00FFF6]" : "text-white/35")}>Bypass</span>
</div>
</div>
{/* ── iOS list rows ── */}
<div className="px-4 pb-4">
<div className="ios-list-group">
<ListRow
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>}
label="输入源"
value={inputLabel}
onClick={() => setLocation("/io")}
/>
<ListRow
icon={<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-4 h-4"><path d="M15 3H9a2 2 0 0 0-2 2v4m8-6h4a2 2 0 0 1 2 2v4M15 3v18m0 0H9a2 2 0 0 1-2-2V9m8 12h4a2 2 0 0 0 2-2V9M7 9H3"/></svg>}
label="输出端口"
value={outputLabel}
onClick={() => setLocation("/io")}
/>
<ListRow
icon={<Sliders size={16} />}
label="Effect"
value={effectOn ? "已开启" : undefined}
toggle
checked={effectOn}
onToggle={(v) => updateSetting({ audio_enable: v ? 1 : 0 })}
/>
<ListRow
icon={<Settings size={16} />}
label="音频设置"
toggle
checked={audioOn}
onToggle={(v) => updateSetting({ audio_enable: v ? 1 : 0 })}
/>
<ListRow
icon={<Gauge size={16} />}
label="VU表"
value={vuLabel}
onClick={() => setLocation("/vu")}
/>
</div>
</div>
{/* Bottom nav */}
<BottomNav />
</div>
);
}