/* ============================================================
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi";
import { cn } from "@/lib/utils";
import {
ChevronRight,
Power,
Settings,
Sliders,
Activity,
Gauge,
ChevronDown,
Zap,
Wifi,
Bluetooth,
} from "lucide-react";
import { useLocation } from "wouter";
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { toast } from "sonner";
import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
// ── 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 (
);
}
// ── 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 (
{icon}
{label}
{toggle ? (
{})} />
) : (
{value && {value}}
)}
);
}
export default function Home() {
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice();
const [, setLocation] = useLocation();
const powerActionRef = useRef(false);
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
const homeText = useMemo((): HomeLocale => {
if (!deviceState) return {} as HomeLocale;
const loc =
deviceState.language === 0 ? localeEn : deviceState.language === 1 ? localeZhHK : localeZh;
return (loc.home ?? {}) as HomeLocale;
}, [deviceState]);
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false);
const knobDraggingRef = useRef(false);
const knobStartAngleRef = useRef(0);
const knobStartVolRef = useRef(0);
const knobLastVolRef = useRef(localVol);
useEffect(() => {
if (!isDragging.current && deviceState?.volume !== undefined) {
setLocalVol(deviceState.volume);
}
}, [deviceState?.volume]);
useEffect(() => {
knobLastVolRef.current = localVol;
}, [localVol]);
const handleVolChange = useCallback((e: React.ChangeEvent) => {
const v = Number(e.target.value);
setLocalVol(v);
setVolume(v);
}, [setVolume]);
const handlePowerOff = useCallback(async () => {
if (powerActionRef.current) return;
powerActionRef.current = true;
try {
if (isDemoMode) {
toast.info(homeText.powerOffToastDemo ?? "演示模式:已断开与设备的连接");
disconnect();
return;
}
if (api) {
try {
await api.powerOff();
} catch {
// 设备可能已掉线或未返回响应,仍断开本地会话
}
}
disconnect();
toast.success(homeText.powerOffToastSent ?? "已发送关机指令");
} finally {
powerActionRef.current = false;
}
}, [api, disconnect, isDemoMode, homeText]);
if (!isConnected) return ;
const ds = deviceState!;
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset";
const effectOn = ds.audio_enable === 1;
const audioOn = ds.audio_enable === 1;
const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
// Slider fill % (0-200 → 0-100%)
const fillPct = (localVol / 200) * 100;
// Knob angle (map 0..200 -> -135..+135 degrees)
const knobAngle = -135 + (localVol / 200) * 270;
const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
return Math.atan2(clientY - cy, clientX - cx);
};
const normalizeDelta = (delta: number) => {
// Wrap to [-PI, PI] to avoid jumps across the -PI/PI boundary.
const pi2 = Math.PI * 2;
let d = ((delta + Math.PI) % pi2) - Math.PI;
if (d < -Math.PI) d += pi2;
return d;
};
const beginKnobDrag = (e: React.PointerEvent) => {
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
knobDraggingRef.current = true;
knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect);
knobStartVolRef.current = knobLastVolRef.current;
isDragging.current = true;
el.setPointerCapture(e.pointerId);
};
const moveKnobDrag = (e: React.PointerEvent) => {
if (!knobDraggingRef.current) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
const a = pointAngle(e.clientX, e.clientY, rect);
const delta = normalizeDelta(a - knobStartAngleRef.current);
// 270deg travel -> full scale; 1 rad maps to ~200/(1.5*pi) volume steps
const stepsPerRad = 200 / (Math.PI * 1.5);
const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad)));
setLocalVol(next);
setVolume(next);
};
const endKnobDrag = (e: React.PointerEvent) => {
if (!knobDraggingRef.current) return;
knobDraggingRef.current = false;
isDragging.current = false;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
};
return (
{/* ── Top bar ── */}
{/* ── Device render image ── */}
{/* Stylized device illustration */}
{/* Device body */}
{/* Left ports */}
{/* Big hole (left) */}
{/* Two small holes (right, stacked) */}
{/* VU display area */}
{ds.audioFormat}
{volToDB(localVol)}
{/* Mini VU bars */}
{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 (
13 ? "#f59e0b" : "#00FFF6",
opacity: 0.7,
}} />
);
})}
{/* Right knob */}
{/* Brand label */}
LUXSIN
{/* ── Volume slider ── */}
{/* Track fill - 根据按钮位置调整 */}
{ isDragging.current = true; }}
onMouseUp={() => { isDragging.current = false; }}
onTouchStart={() => { isDragging.current = true; }}
onTouchEnd={() => { isDragging.current = false; }}
onChange={handleVolChange}
/>
{/* dB value */}
{volToDB(localVol)}
{/* Bluetooth info */}
{ds.bt_status !== 0 && (
{ds.bt_srcname}
{ds.bt_title && (
{ds.bt_title}
{ds.bt_artist && - {ds.bt_artist}}
)}
{/* Playback controls */}
)}
{/* ── 4 quick-action circle buttons ── */}
{/* Power */}
{homeText.power ?? "电源"}
{/* EQ */}
HP-EQ
{/* Effect */}
Effect
{/* Bypass (DSP) */}
Bypass
{/* ── iOS list rows ── */}
}
label={homeText.input ?? "输入源"}
value={inputLabel}
onClick={() => setLocation("/io")}
/>
}
label={homeText.output ?? "输出端口"}
value={outputLabel}
onClick={() => setLocation("/io")}
/>
}
label="Effect"
value={effectOn ? "已开启" : undefined}
toggle
checked={effectOn}
onToggle={(v) => updateSetting({ audio_enable: v ? 1 : 0 })}
/>
}
label={homeText.audioSet ?? "音频设置"}
onClick={() => setLocation("/audio")}
/>
}
label={homeText.vu ?? "VU表"}
value={vuLabel}
onClick={() => setLocation("/vu")}
/>
{homeText.powerOffConfirmTitle ?? "确认关闭电源?"}
{homeText.powerOffConfirmDesc ??
"设备将关机并断开与本应用的连接,是否继续?"}
{homeText.powerOffConfirmCancel ?? "取消"}
void handlePowerOff()}
>
{homeText.powerOffConfirmOk ?? "关闭电源"}
{/* Bottom nav */}
);
}