/* ============================================================ 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 ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider"; 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; /** 与 IOPage 输出选项 index 2(耳机)一致 */ const HEADPHONE_OUTPUT_INDEX = 2; /** 旋律主音在 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, thumbnailSrc, }: { icon: React.ReactNode; label: string; value?: string; onClick?: () => void; toggle?: boolean; checked?: boolean; onToggle?: (v: boolean) => void; /** Optional small thumbnail shown between value text and chevron. */ thumbnailSrc?: string; }) { return (
{icon}
{label}
{toggle ? ( {})} /> ) : (
{thumbnailSrc && ( )} {value && {value}}
)}
); } export default function Home() { const { isConnected, deviceState, setVolume, updateSetting, api, 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 [volumeConfirm, setVolumeConfirm] = useState(null); 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 volumeConfirmCopy = useMemo(() => { if (volumeConfirm === "min") { return { title: homeText.volumeMinConfirmTitle ?? "确认将音量设为最小?", desc: homeText.volumeMinConfirmDesc ?? "耳机音量将调至最低(静音),是否继续?", ok: homeText.volumeMinConfirmOk ?? "设为最小", }; } if (volumeConfirm === "max") { return { title: homeText.volumeMaxConfirmTitle ?? "确认将音量设为最大?", desc: homeText.volumeMaxConfirmDesc ?? "耳机音量将调至最大,是否继续?", ok: homeText.volumeMaxConfirmOk ?? "设为最大", }; } return null; }, [volumeConfirm, homeText]); 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 ?? "演示模式:已发送关机指令"); return; } if (api) { try { await api.powerOff(); } catch { // 设备可能已关机或未返回响应,仍保留当前页面与会话状态 } } toast.success(homeText.powerOffToastSent ?? "已发送关机指令"); } finally { powerActionRef.current = false; } }, [api, 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); }; }, []); const volumePassthroughActive = (deviceState?.dacVolumeDirect ?? 0) === 1 || (deviceState?.dacVolumeDirect ?? 0) === 2; const volumeControlsLocked = volumePassthroughActive && (deviceState?.output ?? 0) !== HEADPHONE_OUTPUT_INDEX; const volumePassthroughHint = homeText.volumePassthroughLockedHint ?? homeText.volumePassthroughLockedToast ?? "当前为音量直通模式,无法调节音量"; const guardVolumeChange = useCallback( (action: () => void) => { if (volumeControlsLocked) return; action(); }, [volumeControlsLocked], ); 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) => { if (volumeControlsLocked) return; 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; if (volumeControlsLocked) return; 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 ── */}
{volumeControlsLocked && (

{volumePassthroughHint}

)}
{/* Track fill - 根据按钮位置调整 */}
{ if (volumeControlsLocked) return; setLocalVol(v); }} onDragStart={() => { if (volumeControlsLocked) return; isDragging.current = true; }} onDragEnd={() => { isDragging.current = false; }} onRelease={(v) => { if (volumeControlsLocked) return; setVolume(v); }} />
{/* 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 {homeText.doubleClickHint ?? "Double-click to enter"}
{/* Effect */}
Effect {homeText.doubleClickHint ?? "Double-click to enter"}
{/* Bypass (DSP) */}
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} thumbnailSrc={`${import.meta.env.BASE_URL}vu/vu${(ds.vu ?? 0) + 1}.png`} 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`; }} />
{ if (!open) setVolumeConfirm(null); }} > {volumeConfirmCopy?.title} {volumeConfirmCopy?.desc} {homeText.powerOffConfirmCancel ?? "取消"} { if (volumeControlsLocked) { setVolumeConfirm(null); return; } if (volumeConfirm === "min") { setLocalVol(0); setVolume(0); } else if (volumeConfirm === "max") { setLocalVol(200); setVolume(200); } setVolumeConfirm(null); }} > {volumeConfirmCopy?.ok} {homeText.powerOffConfirmTitle ?? "确认关闭电源?"} {homeText.powerOffConfirmDesc ?? "设备将关机并断开与本应用的连接,是否继续?"} {homeText.powerOffConfirmCancel ?? "取消"} void handlePowerOff()} > {homeText.powerOffConfirmOk ?? "关闭电源"} {/* Bottom nav */}
); }