Initial commit: 提交控制器版本2核心代码
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
/* ============================================================
|
||||
DASHBOARD — Luxsin X9 Controller
|
||||
Design: Main overview with volume knob, input/output, BT status
|
||||
============================================================ */
|
||||
import Layout from "@/components/Layout";
|
||||
import GlassToggle from "@/components/GlassToggle";
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
import {
|
||||
INPUT_LABELS,
|
||||
OUTPUT_LABELS,
|
||||
} from "@/lib/luxsinApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Activity,
|
||||
Bluetooth,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Cpu,
|
||||
Headphones,
|
||||
Music,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
// ---- Volume Knob ----
|
||||
function VolumeKnob() {
|
||||
const { deviceState, setVolume } = useDevice();
|
||||
const volume = deviceState?.volume ?? 0;
|
||||
const percent = volume / 200;
|
||||
const isDragging = useRef(false);
|
||||
const startY = useRef(0);
|
||||
const startVol = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
isDragging.current = true;
|
||||
startY.current = e.clientY;
|
||||
startVol.current = volume;
|
||||
document.addEventListener("mousemove", handleMouseMove as EventListener);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, [volume]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const delta = (startY.current - e.clientY) * 1.5;
|
||||
const newVol = Math.max(0, Math.min(200, Math.round(startVol.current + delta)));
|
||||
setVolume(newVol);
|
||||
}, [setVolume]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove as EventListener);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [handleMouseMove]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -2 : 2;
|
||||
const newVol = Math.max(0, Math.min(200, volume + delta));
|
||||
setVolume(newVol);
|
||||
}, [volume, setVolume]);
|
||||
|
||||
// SVG arc calculation
|
||||
const size = 160;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = 60;
|
||||
const startAngle = 135;
|
||||
const endAngle = 405; // 270 degree sweep
|
||||
const sweepAngle = (endAngle - startAngle) * percent;
|
||||
|
||||
const polarToXY = (angle: number, radius: number) => {
|
||||
const rad = ((angle - 90) * Math.PI) / 180;
|
||||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||||
};
|
||||
|
||||
const arcPath = (start: number, end: number, radius: number) => {
|
||||
const s = polarToXY(start, radius);
|
||||
const e = polarToXY(end, radius);
|
||||
const large = end - start > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${large} 1 ${e.x} ${e.y}`;
|
||||
};
|
||||
|
||||
const thumbPos = polarToXY(startAngle + sweepAngle, r);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="relative select-none"
|
||||
style={{ width: size, height: size, cursor: "ns-resize" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<svg width={size} height={size}>
|
||||
{/* Track */}
|
||||
<path
|
||||
d={arcPath(startAngle, endAngle, r)}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Progress */}
|
||||
{percent > 0 && (
|
||||
<path
|
||||
d={arcPath(startAngle, startAngle + sweepAngle, r)}
|
||||
fill="none"
|
||||
stroke="#00FFF6"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
style={{ filter: "drop-shadow(0 0 6px rgba(0,255,246,0.7))" }}
|
||||
/>
|
||||
)}
|
||||
{/* Thumb dot */}
|
||||
<circle
|
||||
cx={thumbPos.x}
|
||||
cy={thumbPos.y}
|
||||
r="5"
|
||||
fill="#00FFF6"
|
||||
style={{ filter: "drop-shadow(0 0 8px rgba(0,255,246,0.9))" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center display */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="w-20 h-20 rounded-full flex flex-col items-center justify-center"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(0,255,246,0.08) 0%, rgba(13,17,23,0.9) 70%)",
|
||||
border: "1px solid rgba(0,255,246,0.15)",
|
||||
boxShadow: "0 0 20px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)",
|
||||
}}>
|
||||
<span className="text-[#00FFF6] text-2xl font-bold leading-none"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{volume}
|
||||
</span>
|
||||
<span className="text-white/30 text-[9px] mt-0.5 tracking-widest">VOL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume controls */}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
onClick={() => setVolume(Math.max(0, volume - (deviceState?.soundStep ?? 2)))}
|
||||
className="btn-glass w-8 h-8 flex items-center justify-center"
|
||||
>
|
||||
<ChevronDown size={14} className="text-white/60" />
|
||||
</button>
|
||||
<span className="text-white/30 text-xs w-16 text-center" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{Math.round(percent * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setVolume(Math.min(200, volume + (deviceState?.soundStep ?? 2)))}
|
||||
className="btn-glass w-8 h-8 flex items-center justify-center"
|
||||
>
|
||||
<ChevronUp size={14} className="text-white/60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Input Selector ----
|
||||
function InputSelector() {
|
||||
const { deviceState, setInput } = useDevice();
|
||||
const current = deviceState?.input ?? 0;
|
||||
|
||||
const inputIcons: Record<number, React.ReactNode> = {
|
||||
0: <span className="text-[9px] font-bold">USB</span>,
|
||||
1: <span className="text-[9px] font-bold">C</span>,
|
||||
2: <span className="text-[9px] font-bold">CO</span>,
|
||||
3: <span className="text-[9px] font-bold">OPT</span>,
|
||||
4: <Bluetooth size={10} />,
|
||||
5: <span className="text-[9px] font-bold">ARC</span>,
|
||||
6: <span className="text-[9px] font-bold">RCA</span>,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-3">Input Source</p>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{INPUT_LABELS.map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setInput(i)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 py-2.5 px-1 rounded-[12px] transition-all duration-200",
|
||||
current === i ? "btn-pill-active" : "btn-pill-inactive"
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-[11px]", current === i ? "text-[#00FFF6]" : "text-white/40")}>
|
||||
{inputIcons[i]}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[9px] font-semibold leading-tight text-center",
|
||||
current === i ? "text-[#00FFF6]" : "text-white/40"
|
||||
)} style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Output Selector ----
|
||||
function OutputSelector() {
|
||||
const { deviceState, setOutput } = useDevice();
|
||||
const current = deviceState?.output ?? 0;
|
||||
|
||||
const outputIcons = [
|
||||
<span key="xlr" className="text-[9px] font-bold">XLR</span>,
|
||||
<span key="rca" className="text-[9px] font-bold">RCA</span>,
|
||||
<Headphones key="hp" size={10} />,
|
||||
<span key="both" className="text-[8px] font-bold">BOTH</span>,
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-3">Output</p>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{OUTPUT_LABELS.map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setOutput(i)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 py-2.5 px-1 rounded-[12px] transition-all duration-200",
|
||||
current === i ? "btn-pill-active" : "btn-pill-inactive"
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-[11px]", current === i ? "text-[#00FFF6]" : "text-white/40")}>
|
||||
{outputIcons[i]}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[9px] font-semibold leading-tight text-center",
|
||||
current === i ? "text-[#00FFF6]" : "text-white/40"
|
||||
)} style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- BT Player ----
|
||||
function BluetoothPlayer() {
|
||||
const { deviceState, updateSetting } = useDevice();
|
||||
if (!deviceState) return null;
|
||||
|
||||
const { bt_status, bt_title, bt_artist, bt_srcname } = deviceState;
|
||||
const isPlaying = bt_status === 1;
|
||||
const isConnected = bt_status > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("glass-card p-4 transition-all duration-300", isConnected ? "glass-cyan" : "")}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bluetooth size={14} className={isConnected ? "text-[#00FFF6]" : "text-white/30"} />
|
||||
<span className="section-label">Bluetooth</span>
|
||||
{isConnected && <div className="dot-active ml-auto" />}
|
||||
</div>
|
||||
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<p className="text-white text-sm font-semibold truncate" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||
{bt_title || "Unknown Track"}
|
||||
</p>
|
||||
<p className="text-white/50 text-xs truncate mt-0.5">{bt_artist || bt_srcname}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => updateSetting({ bt_next: 0 })}
|
||||
className="btn-glass w-9 h-9 flex items-center justify-center"
|
||||
>
|
||||
<SkipBack size={14} className="text-white/60" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting({ bt_play: 1 })}
|
||||
className={cn(
|
||||
"w-11 h-11 rounded-full flex items-center justify-center transition-all duration-200",
|
||||
isPlaying
|
||||
? "btn-cyan"
|
||||
: "btn-glass"
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<div className="flex gap-0.5">
|
||||
<div className="w-1 h-4 bg-current rounded-sm" />
|
||||
<div className="w-1 h-4 bg-current rounded-sm" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-0 h-0 border-l-[10px] border-l-current border-y-[6px] border-y-transparent ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting({ bt_next: 1 })}
|
||||
className="btn-glass w-9 h-9 flex items-center justify-center"
|
||||
>
|
||||
<SkipForward size={14} className="text-white/60" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center py-4 text-white/30">
|
||||
<Bluetooth size={24} className="mb-2 opacity-30" />
|
||||
<p className="text-xs">Not connected</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Status Cards ----
|
||||
function StatusCards() {
|
||||
const { deviceState, updateSetting } = useDevice();
|
||||
if (!deviceState) return null;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: "DSP",
|
||||
value: deviceState.dsp_enable === 0 ? "Enabled" : "Disabled",
|
||||
active: deviceState.dsp_enable === 0,
|
||||
icon: <Cpu size={14} />,
|
||||
toggle: () => updateSetting({ dsp_enable: deviceState.dsp_enable === 0 ? 1 : 0 }),
|
||||
},
|
||||
{
|
||||
label: "Audio Out",
|
||||
value: deviceState.audio_enable === 1 ? "Enabled" : "Muted",
|
||||
active: deviceState.audio_enable === 1,
|
||||
icon: deviceState.audio_enable === 1 ? <Volume2 size={14} /> : <VolumeX size={14} />,
|
||||
toggle: () => updateSetting({ audio_enable: deviceState.audio_enable === 1 ? 0 : 1 }),
|
||||
},
|
||||
{
|
||||
label: "PEQ",
|
||||
value: deviceState.peqEnable === 1 ? "Enabled" : "Disabled",
|
||||
active: deviceState.peqEnable === 1,
|
||||
icon: <Activity size={14} />,
|
||||
toggle: () => updateSetting({ peqEnable: deviceState.peqEnable === 1 ? 0 : 1 }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{cards.map((card) => (
|
||||
<button
|
||||
key={card.label}
|
||||
onClick={card.toggle}
|
||||
className={cn(
|
||||
"glass-card p-3 rounded-[16px] text-left transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]",
|
||||
card.active && "glass-cyan"
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-2", card.active ? "text-[#00FFF6]" : "text-white/30")}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<p className={cn("text-[11px] font-semibold", card.active ? "text-[#00FFF6]" : "text-white/50")}
|
||||
style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="text-white/30 text-[10px] mt-0.5">{card.value}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- VU Meter Visual ----
|
||||
function VuMeterDisplay() {
|
||||
const { deviceState, updateSetting } = useDevice();
|
||||
if (!deviceState) return null;
|
||||
|
||||
const { vu, vu_count } = deviceState;
|
||||
const bars = 12;
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={13} className="text-[#00FFF6]" />
|
||||
<span className="section-label">VU Meter Style</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: vu_count || 5 }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => updateSetting({ vu: i })}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-[6px] text-[9px] font-bold transition-all duration-200",
|
||||
i === vu ? "btn-pill-active" : "btn-pill-inactive"
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Animated VU bars */}
|
||||
<div className="flex items-end gap-0.5 h-10">
|
||||
{Array.from({ length: bars }, (_, i) => {
|
||||
const height = Math.random() * 100;
|
||||
const isHot = i >= bars - 3;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-sm transition-all duration-100"
|
||||
style={{
|
||||
height: `${20 + height * 0.8}%`,
|
||||
background: isHot
|
||||
? "rgba(255,80,80,0.7)"
|
||||
: i >= bars - 5
|
||||
? "rgba(255,200,0,0.7)"
|
||||
: "rgba(0,255,246,0.6)",
|
||||
boxShadow: isHot
|
||||
? "0 0 4px rgba(255,80,80,0.5)"
|
||||
: "0 0 4px rgba(0,255,246,0.3)",
|
||||
animation: `vu-pulse ${0.3 + Math.random() * 0.4}s ease-in-out infinite alternate`,
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Balance Slider ----
|
||||
function BalanceControl() {
|
||||
const { deviceState, setBalance } = useDevice();
|
||||
const balance = deviceState?.balance ?? 0;
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="section-label">L/R Balance</span>
|
||||
<span className="text-[#00FFF6] text-xs font-mono" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{balance === 0 ? "Center" : balance > 0 ? `R+${balance}` : `L${balance}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white/40 text-xs font-bold">L</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-10}
|
||||
max={10}
|
||||
value={balance}
|
||||
onChange={(e) => setBalance(parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
style={{
|
||||
background: `linear-gradient(to right,
|
||||
rgba(0,255,246,0.3) 0%,
|
||||
rgba(0,255,246,0.3) ${((balance + 10) / 20) * 100}%,
|
||||
rgba(255,255,255,0.08) ${((balance + 10) / 20) * 100}%,
|
||||
rgba(255,255,255,0.08) 100%)`
|
||||
}}
|
||||
/>
|
||||
<span className="text-white/40 text-xs font-bold">R</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Device Info ----
|
||||
function DeviceInfo() {
|
||||
const { deviceState } = useDevice();
|
||||
if (!deviceState) return null;
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
<p className="section-label mb-3">Device Info</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: "Model", value: deviceState.device },
|
||||
{ label: "Firmware", value: deviceState.version },
|
||||
{ label: "MAC", value: deviceState.mac },
|
||||
{ label: "Format", value: deviceState.audioFormat },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<span className="text-white/40 text-xs">{label}</span>
|
||||
<span className="text-white/80 text-xs font-medium" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{value || "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Main Dashboard ----
|
||||
export default function Dashboard() {
|
||||
const { deviceState } = useDevice();
|
||||
|
||||
return (
|
||||
<Layout title="Dashboard" subtitle="Luxsin X9 Pre-Amplifier">
|
||||
<div className="max-w-5xl mx-auto stagger-children">
|
||||
|
||||
{/* Hero row: Volume + Input/Output */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4 animate-fade-in-up">
|
||||
{/* Volume Knob Card */}
|
||||
<div className="glass-card p-6 flex flex-col items-center">
|
||||
<p className="section-label mb-4 self-start">Master Volume</p>
|
||||
<VolumeKnob />
|
||||
</div>
|
||||
|
||||
{/* Input / Output */}
|
||||
<div className="lg:col-span-2 glass-card p-5 space-y-5">
|
||||
<InputSelector />
|
||||
<div className="divider-glass" />
|
||||
<OutputSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status + BT row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4 animate-fade-in-up" style={{ animationDelay: "60ms" }}>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<StatusCards />
|
||||
<BalanceControl />
|
||||
</div>
|
||||
<BluetoothPlayer />
|
||||
</div>
|
||||
|
||||
{/* VU + Device Info */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 animate-fade-in-up" style={{ animationDelay: "120ms" }}>
|
||||
<VuMeterDisplay />
|
||||
<DeviceInfo />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user