diff --git a/client/src/components/ConnectScreen.tsx b/client/src/components/ConnectScreen.tsx deleted file mode 100644 index 13d394c..0000000 --- a/client/src/components/ConnectScreen.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* ============================================================ - CONNECT SCREEN — Luxsin X9 Controller - Design: Pure black iOS App style, glass card, #00FFF6 accent - ============================================================ */ -import { useDevice } from "@/contexts/DeviceContext"; -import { Loader2, Wifi } from "lucide-react"; -import { useState } from "react"; - -export default function ConnectScreen() { - const { ip, setIp, connect, isConnecting, error, setDemoMode, isDemoMode } = useDevice(); - const [inputIp, setInputIp] = useState(ip); - - const handleConnect = async () => { - const targetIp = inputIp.trim(); - if (!targetIp) return; - setIp(targetIp); - setDemoMode(false); - await connect(false, targetIp); - }; - - const handleDemo = async () => { - setDemoMode(true); - await connect(true); - }; - - return ( -
- {/* Logo / Brand */} -
-
- Luxsin -
-

- Luxsin X8 Controller -

- -
- - {/* Connection card */} -
-
- -
- - setInputIp(e.target.value)} - placeholder="192.168.2.30" - className="w-full pl-9 pr-4 py-3.5 rounded-[12px] text-[15px] text-white outline-none transition-all" - style={{ - background: "rgba(44,44,46,0.8)", - border: "1px solid rgba(255,255,255,0.08)", - fontFamily: "inherit", - }} - onFocus={(e) => { e.target.style.borderColor = "rgba(0,255,246,0.4)"; e.target.style.boxShadow = "0 0 0 3px rgba(0,255,246,0.06)"; }} - onBlur={(e) => { e.target.style.borderColor = "rgba(255,255,255,0.08)"; e.target.style.boxShadow = "none"; }} - onKeyDown={(e) => e.key === "Enter" && handleConnect()} - /> -
-

请确保与设备处于同一局域网

-
- - {error && ( -
- {error} -
- )} - - -
- -
- ); -} diff --git a/client/src/components/ConnectionPlaceholder.tsx b/client/src/components/ConnectionPlaceholder.tsx new file mode 100644 index 0000000..564cdf9 --- /dev/null +++ b/client/src/components/ConnectionPlaceholder.tsx @@ -0,0 +1,34 @@ +import { useDevice } from "@/contexts/DeviceContext"; +import { Loader2, Wifi } from "lucide-react"; +import BottomNav from "@/components/BottomNav"; + +/** 自动连接设备时的占位(替代原 ConnectScreen) */ +export default function ConnectionPlaceholder() { + const { ip, isConnecting, error } = useDevice(); + + return ( +
+
+ +
+ {isConnecting ? ( + <> + +

Connecting to {ip}…

+ + ) : ( + <> +

Connection failed

+

+ {error ?? "Check the device IP and your network connection."} +

+

+ Specify the device in the URL, e.g.{" "} + ?ip=10.0.0.119 +

+ + )} + +
+ ); +} diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index 1c410bd..2ebf9b9 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -9,6 +9,7 @@ import { PeqFilter, PeqState, } from "@/lib/luxsinApi"; +import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp"; interface DeviceContextType { ip: string; @@ -39,7 +40,7 @@ interface DeviceContextType { const DeviceContext = createContext(null); export function DeviceProvider({ children }: { children: React.ReactNode }) { - const [ip, setIp] = useState(() => localStorage.getItem("luxsin_ip") || "192.168.2.30"); + const [ip, setIp] = useState(resolveInitialDeviceIp); const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isDemoMode, setDemoMode] = useState(false); @@ -51,6 +52,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { const [api, setApi] = useState(null); const lastMsgCountRef = useRef(-1); const pollIntervalRef = useRef | null>(null); + const autoConnectStartedRef = useRef(false); const saveIp = useCallback((newIp: string) => { setIp(newIp); @@ -202,6 +204,17 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { if (!isDemoMode && api) api.setBalance(v).catch(console.error); }, [api, isDemoMode]); + // 启动时根据 ?ip= 或已存 IP 自动连接(默认进入首页,无需连接页) + useEffect(() => { + if (autoConnectStartedRef.current) return; + autoConnectStartedRef.current = true; + + const urlIp = getDeviceIpFromUrl(); + const targetIp = urlIp ?? ip; + if (urlIp) saveIp(urlIp); + void connect(false, targetIp); + }, [connect, ip, saveIp]); + // Polling for changes useEffect(() => { if (!isConnected || isDemoMode || !api) return; diff --git a/client/src/lib/deviceIp.ts b/client/src/lib/deviceIp.ts new file mode 100644 index 0000000..8a9fb7a --- /dev/null +++ b/client/src/lib/deviceIp.ts @@ -0,0 +1,11 @@ +/** 从地址栏 ?ip= 读取设备 IP(hash 路由下 query 仍在 search 上) */ +export function getDeviceIpFromUrl(): string | null { + if (typeof window === "undefined") return null; + const ip = new URLSearchParams(window.location.search).get("ip"); + const trimmed = ip?.trim(); + return trimmed || null; +} + +export function resolveInitialDeviceIp(): string { + return getDeviceIpFromUrl() ?? localStorage.getItem("luxsin_ip") ?? "192.168.2.30"; +} diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 6fbed9f..7950b4e 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -12,7 +12,8 @@ "powerOffConfirmCancel": "Cancel", "powerOffToastDemo": "Demo mode: disconnected from the device.", "powerOffToastSent": "Shutdown command sent.", - "doubleClickHint": "Double-click to enter" + "doubleClickHint": "Double-click to enter", + "backToLegacy": "Legacy version" }, "vuPage": { "title": "VU meter gallery" @@ -587,7 +588,7 @@ "deviceName": "Device name", "firmwareVersion": "Firmware version", "macAddress": "MAC address", - "disconnect": "Disconnect" + "deviceIp": "Device IP" } }, "prompt": { diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 701bd2e..2aa0e8f 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -12,7 +12,8 @@ "powerOffConfirmCancel": "取消", "powerOffToastDemo": "演示模式:已中斷與裝置的連線", "powerOffToastSent": "已傳送關機指令", - "doubleClickHint": "按兩下進入" + "doubleClickHint": "按兩下進入", + "backToLegacy": "返回舊版" }, "vuPage": { "title": "VU 表庫" @@ -403,7 +404,7 @@ "deviceName": "裝置名稱", "firmwareVersion": "韌體版本", "macAddress": "MAC 位址", - "disconnect": "中斷連線" + "deviceIp": "裝置 IP" } }, "prompt": { diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 0c11587..b75dcdd 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -12,7 +12,8 @@ "powerOffConfirmCancel": "取消", "powerOffToastDemo": "演示模式:已断开与设备的连接", "powerOffToastSent": "已发送关机指令", - "doubleClickHint": "双击进入" + "doubleClickHint": "双击进入", + "backToLegacy": "返回旧版" }, "vuPage": { "title": "VU 表库" @@ -404,7 +405,7 @@ "deviceName": "设备名称", "firmwareVersion": "固件版本", "macAddress": "MAC 地址", - "disconnect": "断开连接" + "deviceIp": "设备 IP" } }, "prompt": { diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index 81faeaa..ad8fdc0 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -7,7 +7,7 @@ Backend: /sse/question (SSE), /sse/messages, /sse/chats, /sse/clear ============================================================ */ import BottomNav from "@/components/BottomNav"; -import ConnectScreen from "@/components/ConnectScreen"; +import ConnectionPlaceholder from "@/components/ConnectionPlaceholder"; import { useDevice } from "@/contexts/DeviceContext"; import { AI_SPLIT_RESTORE_PATH_KEY, @@ -337,7 +337,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) })(); }, [mac, loadChat]); - // 没有 mac 时(设备未识别完毕)不应该停在 initializing;ConnectScreen 会拦截 + // 没有 mac 时(设备未识别完毕)不应该停在 initializing;未连接时 ConnectionPlaceholder 会拦截 useEffect(() => { if (!mac && initializing) { // 给 mac 一段缓冲期,否则即便最终没有 mac 也要释放欢迎页 @@ -692,7 +692,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) const headerTitle = aiText.title; - if (!isConnected) return ; + if (!isConnected) return ; return (
{ const dist = Math.abs(i - lead); - let amp = 0.14; - if (dist === 0) amp = 0.96; - else if (dist === 1) amp = 0.74; - else if (dist === 2) amp = 0.5; - else if (dist === 3) amp = 0.3; - return Math.min(1, amp * (0.82 + pulse * 0.18)); + 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)); }); } @@ -131,7 +164,7 @@ function ListRow({ } export default function Home() { - const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice(); + const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode, ip } = useDevice(); const [, setLocation] = useLocation(); const powerActionRef = useRef(false); const hpEqLastTapRef = useRef(null); @@ -144,6 +177,8 @@ export default function Home() { 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; @@ -257,28 +292,33 @@ export default function Home() { }; }, []); - const triggerJackEasterEgg = useCallback(() => { - setEasterEggActive(true); - jackProgressRef.current = 0; - const start = performance.now(); + 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 >= EGG_DURATION_MS) { - setEasterEggActive(false); - setVuHeights(idleVuBarHeights()); - eggRafRef.current = null; - return; - } - const beat = Math.floor(elapsed / EGG_BEAT_MS) % EGG_MELODY_LEAD.length; - const pulse = 0.5 + 0.5 * Math.sin(elapsed * 0.02); - setVuHeights(vuHeightsForMelodyBeat(EGG_MELODY_LEAD[beat], pulse)); + 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); - }; - - if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current); - eggRafRef.current = requestAnimationFrame(tick); - }, []); + }, + [], + ); const onJackClick = useCallback( (jack: JackId) => { @@ -288,7 +328,13 @@ export default function Home() { if (jack === expected) { jackProgressRef.current += 1; if (jackProgressRef.current >= JACK_EGG_SEQUENCE.length) { - triggerJackEasterEgg(); + 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; @@ -299,7 +345,7 @@ export default function Home() { jackProgressRef.current = 0; }, JACK_SEQUENCE_TIMEOUT_MS); }, - [easterEggActive, triggerJackEasterEgg], + [easterEggActive, runJackEggAnimation], ); useEffect(() => { @@ -309,7 +355,7 @@ export default function Home() { }; }, []); - if (!isConnected) return ; + if (!isConnected) return ; const ds = deviceState!; const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C"; @@ -471,8 +517,8 @@ export default function Home() { background: isHot ? "#ef4444" : i > 13 ? "#f59e0b" : "#00FFF6", opacity: easterEggActive ? 0.95 : 0.7, transition: easterEggActive - ? "height 90ms ease-out, opacity 120ms ease-out" - : "height 200ms ease-out", + ? "none" + : "height 200ms ease-out, opacity 200ms ease-out", }} /> ); @@ -766,6 +812,15 @@ export default function Home() { 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`; + }} + />
diff --git a/client/src/pages/SystemPage.tsx b/client/src/pages/SystemPage.tsx index e0563be..818501f 100644 --- a/client/src/pages/SystemPage.tsx +++ b/client/src/pages/SystemPage.tsx @@ -90,7 +90,7 @@ type LocaleSettings = { deviceName: string; firmwareVersion: string; macAddress: string; - disconnect: string; + deviceIp: string; }; }; }; @@ -109,7 +109,7 @@ function optionLabels(items: Array<{ index: number; label: string }> | undefined export default function SystemPage() { const [, setLocation] = useLocation(); - const { deviceState, updateSetting, disconnect, refresh, isConnected, api } = useDevice(); + const { deviceState, updateSetting, refresh, isConnected, api, ip } = useDevice(); const ds = deviceState; // 页面加载且已连接时刷新数据 @@ -204,16 +204,10 @@ export default function SystemPage() { {ui.macAddress} {ds?.mac ?? "—"} - - - {/* ── 断开连接 ── */} -
- +
+ {ui.deviceIp ?? "设备 IP"} + {ip || "—"} +
diff --git a/package.json b/package.json index cc53c93..6425238 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "license": "MIT", "scripts": { "dev": "vite --host", - "build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", - "start": "NODE_ENV=production node dist/index.js", + "build": "vite build", + "start": "NODE_ENV=production tsx server/index.ts", "preview": "vite preview --host", "check": "tsc --noEmit", "format": "prettier --write ." diff --git a/server/index.ts b/server/index.ts index 70704f7..0de24f0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,17 +10,13 @@ async function startServer() { const app = express(); const server = createServer(app); - // Serve static files from dist/public in production - const staticPath = - process.env.NODE_ENV === "production" - ? path.resolve(__dirname, "public") - : path.resolve(__dirname, "..", "dist", "public"); + const staticPath = path.resolve(__dirname, "..", "dist"); app.use(express.static(staticPath)); - // Handle client-side routing - serve index.html for all routes + // Handle client-side routing — entry is i.html (see vite rollup input) app.get("*", (_req, res) => { - res.sendFile(path.join(staticPath, "index.html")); + res.sendFile(path.join(staticPath, "i.html")); }); const port = process.env.PORT || 3000; diff --git a/vite.config.ts b/vite.config.ts index 1735015..09abb2f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -150,11 +150,33 @@ function vitePluginManusDebugCollector(): Plugin { }; } -const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector()]; +/** 生产构建时剔除 public 中的 __manus__(仅开发调试使用) */ +function vitePluginExcludeManusFromDist(): Plugin { + const distDir = path.resolve(import.meta.dirname, "dist"); + return { + name: "exclude-manus-from-dist", + closeBundle() { + if (process.env.NODE_ENV !== "production") return; + const manusDir = path.join(distDir, "__manus__"); + if (fs.existsSync(manusDir)) { + fs.rmSync(manusDir, { recursive: true, force: true }); + } + }, + }; +} + +const plugins = [ + react(), + tailwindcss(), + jsxLocPlugin(), + vitePluginManusRuntime(), + vitePluginManusDebugCollector(), + vitePluginExcludeManusFromDist(), +]; export default defineConfig({ plugins, - base: process.env.NODE_ENV === 'production' ? '/x8/v2/public/' : '/', + base: process.env.NODE_ENV === "production" ? "/x8/v2/" : "/", resolve: { alias: { "@": path.resolve(import.meta.dirname, "client", "src"), @@ -169,7 +191,7 @@ export default defineConfig({ build: { target: 'esnext', cssTarget: 'esnext', - outDir: path.resolve(import.meta.dirname, "dist/public"), + outDir: path.resolve(import.meta.dirname, "dist"), emptyOutDir: true, minify: 'terser', terserOptions: {