/* ============================================================
HOME — Luxsin X9 Controller (Main Dashboard)
Design: Reference luxsin_x8_首页.png
Layout (top to bottom):
1. Device area
2. Device status card (运行中/待机/充电 + 电量/模式/环保参数)
3. Device render image (hardware photo)
4. Volume slider with dB display
5. 4 quick-action circle buttons (电源/HP-EQ/Effect/Bypass;HP-EQ/Effect 支持双击开关)
6. iOS list rows (输入源/输出端口/音频设置/系统设置/VU表)
============================================================ */
import { useDevice } from "@/contexts/DeviceContext";
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
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,
Settings2,
Sliders,
Activity,
Gauge,
Undo2,
ChevronDown,
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 "-100dB";
const db = (v - 200) / 2;
return `${db >= 0 ? "+" : ""}${db.toFixed(1)}dB`;
}
/** 耳机插孔彩蛋:大孔 → 上小孔 → 下小孔 */
const JACK_EGG_SEQUENCE = ["lg", "smTop", "smBot"] as const;
type JackId = (typeof JACK_EGG_SEQUENCE)[number];
const VU_BAR_COUNT = 20;
const EGG_DANCE_DURATION_MS = 5000;
const EGG_WAVE_DURATION_MS = 5000;
const EGG_WAVE_HALF_MS = EGG_WAVE_DURATION_MS / 2;
const EGG_BEAT_MS = 400;
const JACK_SEQUENCE_TIMEOUT_MS = 4000;
/** 旋律主音在 20 段 VU 上的位置(约 5s) */
const EGG_MELODY_LEAD = [
0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 19, 17, 15, 13, 11, 9, 7, 5, 3, 1,
0, 4, 8, 12, 16, 19, 16, 12, 8, 4, 0, 2, 5, 9, 13, 17, 19, 14, 10, 6, 2,
0, 3, 7, 11, 15, 18, 15, 11, 7, 3,
];
function idleVuBarHeights(): number[] {
return Array.from({ length: VU_BAR_COUNT }, (_, i) =>
Math.max(0.12, Math.sin((i / VU_BAR_COUNT) * Math.PI) * 0.65 + 0.2),
);
}
/** 彩蛋律动:主峰随节拍移动,每拍起落,全柱叠加波纹 */
function vuHeightsForDance(elapsedMs: number): number[] {
const t = elapsedMs / 1000;
const beatIdx = Math.floor(elapsedMs / EGG_BEAT_MS) % EGG_MELODY_LEAD.length;
const lead = EGG_MELODY_LEAD[beatIdx];
const beatPhase = (elapsedMs % EGG_BEAT_MS) / EGG_BEAT_MS;
const kick = Math.sin(beatPhase * Math.PI);
return Array.from({ length: VU_BAR_COUNT }, (_, i) => {
const dist = Math.abs(i - lead);
let peak = 0.06;
if (dist === 0) peak = 1;
else if (dist === 1) peak = 0.82;
else if (dist === 2) peak = 0.62;
else if (dist === 3) peak = 0.42;
else if (dist === 4) peak = 0.26;
const ripple = 0.35 * Math.max(0, Math.sin(t * 2.75 + i * 0.62));
const sway = 0.1 * Math.sin(t * 1.4 + i * 0.35);
const h = 0.05 + peak * kick + ripple * (0.55 + kick * 0.45) + sway;
return Math.min(1, Math.max(0.05, h));
});
}
/** 彩蛋律动二:海浪形波从左→右,再右→左 */
function vuHeightsForWave(phaseElapsedMs: number): number[] {
const half = EGG_WAVE_HALF_MS;
const ltr = phaseElapsedMs < half;
const localMs = ltr ? phaseElapsedMs : phaseElapsedMs - half;
const t = localMs / 1000;
const direction = ltr ? 1 : -1;
const speed = 0.75;
return Array.from({ length: VU_BAR_COUNT }, (_, i) => {
const phase = t * speed * Math.PI * 2 - direction * i * 0.72;
const wave = (Math.sin(phase) + 1) / 2;
const foam = 0.06 + 0.05 * Math.sin(i * 0.28 + t * 0.5);
return Math.min(1, Math.max(0.06, foam + wave * 0.9));
});
}
// ── 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, ip } = useDevice();
const [, setLocation] = useLocation();
const powerActionRef = useRef(false);
const hpEqLastTapRef = useRef(null);
const hpEqNavigateTimerRef = useRef | null>(null);
const effectLastTapRef = useRef(null);
const effectNavigateTimerRef = useRef | null>(null);
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
const [easterEggActive, setEasterEggActive] = useState(false);
const [vuHeights, setVuHeights] = useState(idleVuBarHeights);
const jackProgressRef = useRef(0);
const jackResetTimerRef = useRef | null>(null);
const eggRafRef = useRef(null);
/** 下次三连击成功时播放的律动:1=旋律,2=海浪(各需单独三连击触发) */
const jackEggNextRef = useRef<1 | 2>(1);
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 knobActivePointerIdRef = useRef(null);
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 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]);
/** HP-EQ 圆钮:单击在短延迟后切换 peqEnable(避免与双击抢);280ms 内第二次点击则取消开关并进入 /eq */
const HP_EQ_DOUBLE_CLICK_MS = 280;
const handleHpEqCircleClick = useCallback(() => {
const now = Date.now();
const last = hpEqLastTapRef.current;
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
hpEqLastTapRef.current = null;
if (hpEqNavigateTimerRef.current) {
clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = null;
}
setLocation("/eq");
return;
}
hpEqLastTapRef.current = now;
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = setTimeout(() => {
hpEqNavigateTimerRef.current = null;
hpEqLastTapRef.current = null;
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
if (bypassOn) {
void updateSetting({ peqEnable: 1, dsp_enable: 1 });
} else {
const on = (deviceState?.peqEnable ?? 0) === 1;
void updateSetting({ peqEnable: on ? 0 : 1 });
}
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.peqEnable, deviceState?.dsp_enable, updateSetting, setLocation]);
/** Effect 圆钮:单击短延迟后切换 audio_enable;280ms 内第二次点击则进入 /effects */
const handleEffectCircleClick = useCallback(() => {
const now = Date.now();
const last = effectLastTapRef.current;
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
effectLastTapRef.current = null;
if (effectNavigateTimerRef.current) {
clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = null;
}
setLocation("/effects");
return;
}
effectLastTapRef.current = now;
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = setTimeout(() => {
effectNavigateTimerRef.current = null;
effectLastTapRef.current = null;
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
if (bypassOn) {
void updateSetting({ audio_enable: 1, dsp_enable: 1 });
} else {
const on = (deviceState?.audio_enable ?? 0) === 1;
void updateSetting({ audio_enable: on ? 0 : 1 });
}
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.audio_enable, deviceState?.dsp_enable, updateSetting, setLocation]);
useEffect(() => {
return () => {
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
};
}, []);
const runJackEggAnimation = useCallback(
(mode: "dance" | "wave") => {
setEasterEggActive(true);
setVuHeights(mode === "dance" ? vuHeightsForDance(0) : vuHeightsForWave(0));
jackProgressRef.current = 0;
const duration = mode === "dance" ? EGG_DANCE_DURATION_MS : EGG_WAVE_DURATION_MS;
const start = performance.now();
const tick = (now: number) => {
const elapsed = now - start;
if (elapsed >= duration) {
setEasterEggActive(false);
setVuHeights(idleVuBarHeights());
eggRafRef.current = null;
return;
}
setVuHeights(
mode === "dance" ? vuHeightsForDance(elapsed) : vuHeightsForWave(elapsed),
);
eggRafRef.current = requestAnimationFrame(tick);
};
if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current);
eggRafRef.current = requestAnimationFrame(tick);
},
[],
);
const onJackClick = useCallback(
(jack: JackId) => {
if (easterEggActive) return;
const expected = JACK_EGG_SEQUENCE[jackProgressRef.current];
if (jack === expected) {
jackProgressRef.current += 1;
if (jackProgressRef.current >= JACK_EGG_SEQUENCE.length) {
if (jackEggNextRef.current === 1) {
runJackEggAnimation("dance");
jackEggNextRef.current = 2;
} else {
runJackEggAnimation("wave");
jackEggNextRef.current = 1;
}
}
} else {
jackProgressRef.current = jack === JACK_EGG_SEQUENCE[0] ? 1 : 0;
}
if (jackResetTimerRef.current) clearTimeout(jackResetTimerRef.current);
jackResetTimerRef.current = setTimeout(() => {
jackProgressRef.current = 0;
}, JACK_SEQUENCE_TIMEOUT_MS);
},
[easterEggActive, runJackEggAnimation],
);
useEffect(() => {
return () => {
if (jackResetTimerRef.current) clearTimeout(jackResetTimerRef.current);
if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current);
};
}, []);
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 vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
// Home 的 volume 取值为 0..200,对应 dB = (v - 200) / 2
// 因此 1 个 volume 单位 = 0.5dB
// AudioPage 的 soundStep:0->0.5dB, 1->1dB, 2->2dB, 3->3dB
// 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6
const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1;
// 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;
knobActivePointerIdRef.current = e.pointerId;
knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect);
knobStartVolRef.current = knobLastVolRef.current;
isDragging.current = true;
el.setPointerCapture(e.pointerId);
};
/** 拖动中只更新本地 UI,不调用 setVolume(避免来回旋时频繁打接口) */
const moveKnobDrag = (e: React.PointerEvent) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) 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);
knobLastVolRef.current = next;
};
const commitKnobDragEnd = (e: React.PointerEvent) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
knobDraggingRef.current = false;
knobActivePointerIdRef.current = null;
isDragging.current = false;
setVolume(knobLastVolRef.current);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
};
const endKnobDrag = (e: React.PointerEvent) => {
commitKnobDragEnd(e);
};
/** 异常丢失 capture 时仍提交一次,避免只松手未触发 pointerup 时漏提交 */
const lostKnobPointerCapture = (e: React.PointerEvent) => {
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
commitKnobDragEnd(e);
};
return (
{/* ── Device render image ── */}
{/* Stylized device illustration */}
{/* Device body */}
{/* Left ports */}
{/* Big hole (left) — 彩蛋第 1 步 */}
{/* Two small holes (right, stacked) */}
{/* VU display area */}
{ds.audioFormat}
{volToDB(localVol)}
{/* Mini VU bars */}
{vuHeights.map((h, i) => {
const isHot = i > 16;
return (
13 ? "#f59e0b" : "#00FFF6",
opacity: easterEggActive ? 0.95 : 0.7,
transition: easterEggActive
? "none"
: "height 200ms ease-out, opacity 200ms ease-out",
}}
/>
);
})}
{/* Right knob */}
{/* Brand label */}
LUXSIN
{/* ── Volume slider ── */}
{ setLocalVol(0); setVolume(0); }}
className="text-white/50 active:text-white transition-colors flex-shrink-0">
{/* Track fill - 根据按钮位置调整 */}
{
const v = Number(e.target.value);
setLocalVol(v);
}}
onPointerDown={() => {
isDragging.current = true;
}}
onPointerUp={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onPointerCancel={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onBlur={(e) => {
isDragging.current = false;
setVolume(Number(e.currentTarget.value));
}}
onKeyUp={(e) => {
const k = e.key;
if (
k === "ArrowLeft" ||
k === "ArrowRight" ||
k === "ArrowUp" ||
k === "ArrowDown" ||
k === "Home" ||
k === "End" ||
k === "PageUp" ||
k === "PageDown"
) {
setVolume(Number(e.currentTarget.value));
}
}}
/>
{ setLocalVol(200); setVolume(200); }}
className="text-white/50 active:text-white transition-colors flex-shrink-0">
{/* dB value */}
{
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
setLocalVol(next);
setVolume(next);
}}
>
-
{volToDB(localVol)}
= 200}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
background: "rgba(44,44,46,0.6)",
border: "1px solid rgba(255,255,255,0.08)",
color: "rgba(255,255,255,0.55)",
}}
onClick={() => {
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
setLocalVol(next);
setVolume(next);
}}
>
+
{/* Bluetooth info */}
{ds.bt_status !== 0 && (
{ds.bt_srcname}
{ds.bt_title && (
{ds.bt_title}
{ds.bt_artist && - {ds.bt_artist}}
)}
{/* Playback controls */}
updateSetting({ bt_next: 0 })}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
updateSetting({ bt_play: 1 })}
className="w-12 h-12 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "transparent", border: "1px solid rgba(0,255,246,0.5)", boxShadow: "0 0 12px rgba(0,255,246,0.4)" }}>
updateSetting({ bt_next: 1 })}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
)}
{/* ── 4 quick-action circle buttons ── */}
{/* Power */}
setPowerConfirmOpen(true)}
>
{homeText.power ?? "电源"}
{/* EQ */}
HP-EQ
{homeText.doubleClickHint ?? "Double-click to enter"}
{/* Effect */}
Effect
{homeText.doubleClickHint ?? "Double-click to enter"}
{/* Bypass (DSP) */}
updateSetting({ dsp_enable: bypassOn ? 1 : 0 })}
>
Bypass
{/* ── iOS list rows ── */}
}
label={homeText.input ?? "输入源"}
value={inputLabel}
onClick={() => setLocation("/io")}
/>
}
label={homeText.output ?? "输出端口"}
value={outputLabel}
onClick={() => setLocation("/io")}
/>
}
label={homeText.audioSet ?? "音频设置"}
onClick={() => setLocation("/audio")}
/>
}
label={homeText.systemSet ?? "系统设置"}
onClick={() => setLocation("/system")}
/>
}
label={homeText.vu ?? "VU表"}
value={vuLabel}
onClick={() => setLocation("/vu")}
/>
}
label={homeText.backToLegacy ?? "返回旧版"}
onClick={() => {
const target = ip.trim();
if (!target) return;
window.location.href = `https://am.luxsinaudio.com/x8/i.html?ip=${encodeURIComponent(target)}#/home`;
}}
/>
{homeText.powerOffConfirmTitle ?? "确认关闭电源?"}
{homeText.powerOffConfirmDesc ??
"设备将关机并断开与本应用的连接,是否继续?"}
{homeText.powerOffConfirmCancel ?? "取消"}
void handlePowerOff()}
>
{homeText.powerOffConfirmOk ?? "关闭电源"}
{/* Bottom nav */}
);
}