537 lines
18 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|