Files
controller-v2/client/src/pages/Dashboard.tsx
T

537 lines
18 KiB
TypeScript

/* ============================================================
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>
);
}