diff --git a/src/components/Trackpad/ControlBar.tsx b/src/components/Trackpad/ControlBar.tsx index bc4785a..d55c0c7 100644 --- a/src/components/Trackpad/ControlBar.tsx +++ b/src/components/Trackpad/ControlBar.tsx @@ -5,6 +5,7 @@ interface ControlBarProps { scrollMode: boolean; modifier: ModifierState; buffer: string; + latency: number | null; onToggleScroll: () => void; onLeftClick: () => void; onRightClick: () => void; @@ -16,6 +17,7 @@ export const ControlBar: React.FC = ({ scrollMode, modifier, buffer, + latency, onToggleScroll, onLeftClick, onRightClick, @@ -52,25 +54,54 @@ export const ControlBar: React.FC = ({ } }; + const getLatencyColor = (ms: number) => { + if (ms < 50) return { css: "text-success", fill: "#22c55e" }; + if (ms < 150) return { css: "text-warning", fill: "#eab308" }; + return { css: "text-error", fill: "#ef4444" }; + }; + + const WifiIcon = ({ fill }: { fill: string }) => ( + + + + + + + ); + return ( -
- - - - {/* +
+
+ {latency !== null ? (() => { + const { css, fill } = getLatencyColor(latency); + return ( + + + {latency}ms + + ); + })() : ( + --- + )} +
+
+ + + + {/* */} - - - + + + +
); }; \ No newline at end of file diff --git a/src/hooks/useRemoteConnection.ts b/src/hooks/useRemoteConnection.ts index 9adfbfd..a9c00d4 100644 --- a/src/hooks/useRemoteConnection.ts +++ b/src/hooks/useRemoteConnection.ts @@ -1,8 +1,10 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import type { RemoteMessage } from '@/types'; export const useRemoteConnection = () => { const wsRef = useRef(null); const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('disconnected'); + const [latency, setLatency] = useState(null); useEffect(() => { let isMounted = true; @@ -11,6 +13,9 @@ export const useRemoteConnection = () => { const wsUrl = `${protocol}//${host}/ws`; let reconnectTimer: NodeJS.Timeout; + let heartbeatTimer: NodeJS.Timeout; + let reconnectDelay = 1000; + const MAX_RECONNECT_DELAY = 30000; const connect = () => { if (!isMounted) return; @@ -20,6 +25,7 @@ export const useRemoteConnection = () => { wsRef.current.onopen = null; wsRef.current.onclose = null; wsRef.current.onerror = null; + wsRef.current.onmessage = null; wsRef.current.close(); wsRef.current = null; } @@ -27,22 +33,57 @@ export const useRemoteConnection = () => { console.log(`Connecting to ${wsUrl}`); setStatus('connecting'); const socket = new WebSocket(wsUrl); + wsRef.current = socket; socket.onopen = () => { - if (isMounted) setStatus('connected'); + if (!isMounted) return; + setStatus('connected'); + reconnectDelay = 1000; + + // Fire first ping right away so the UI updates instantly + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } + + clearInterval(heartbeatTimer); + heartbeatTimer = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } + }, 3000); }; - socket.onclose = () => { - if (isMounted) { - setStatus('disconnected'); - reconnectTimer = setTimeout(connect, 3000); + + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'pong') { + const ts = Number(msg.timestamp); + const rtt = Date.now() - ts; + if (Number.isFinite(ts) && Number.isFinite(rtt) && rtt >= 0 && rtt < 60000) { + setLatency(rtt); + } + } + } catch { + // ignore non-JSON or malformed server messages } }; + + socket.onclose = () => { + if (!isMounted) return; + setStatus('disconnected'); + setLatency(null); + wsRef.current = null; + clearInterval(heartbeatTimer); + + const delay = reconnectDelay; + reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + reconnectTimer = setTimeout(connect, delay); + }; + socket.onerror = (e) => { - console.error("WS Error", e); + console.error('WS Error', e); socket.close(); }; - - wsRef.current = socket; }; // Defer to next tick so React Strict Mode's immediate unmount @@ -53,31 +94,33 @@ export const useRemoteConnection = () => { isMounted = false; clearTimeout(initialTimer); clearTimeout(reconnectTimer); + clearInterval(heartbeatTimer); + if (wsRef.current) { - // Nullify handlers to prevent cascading error/close events wsRef.current.onopen = null; wsRef.current.onclose = null; wsRef.current.onerror = null; + wsRef.current.onmessage = null; wsRef.current.close(); wsRef.current = null; } }; }, []); - const send = useCallback((msg: any) => { + const send = useCallback((msg: RemoteMessage) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(msg)); } }, []); - const sendCombo = useCallback((msg: string[]) => { + const sendCombo = useCallback((keys: string[]) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ - type: "combo", - keys: msg, + type: 'combo', + keys, })); } }, []); - return { status, send, sendCombo }; + return { status, latency, send, sendCombo }; }; diff --git a/src/hooks/useTrackpadGesture.ts b/src/hooks/useTrackpadGesture.ts index a178773..7d6c4bd 100644 --- a/src/hooks/useTrackpadGesture.ts +++ b/src/hooks/useTrackpadGesture.ts @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import { TOUCH_MOVE_THRESHOLD, TOUCH_TIMEOUT, PINCH_THRESHOLD, calculateAccelerationMult } from '../utils/math'; +import type { RemoteMessage } from '@/types'; interface TrackedTouch { identifier: number; @@ -17,7 +18,7 @@ const getTouchDistance = (a: TrackedTouch, b: TrackedTouch): number => { }; export const useTrackpadGesture = ( - send: (msg: any) => void, + send: (msg: RemoteMessage) => void, scrollMode: boolean, sensitivity: number = 1.5, invertScroll: boolean = false, diff --git a/src/routes/trackpad.tsx b/src/routes/trackpad.tsx index cf21e0b..ff58756 100644 --- a/src/routes/trackpad.tsx +++ b/src/routes/trackpad.tsx @@ -33,7 +33,7 @@ function TrackpadPage() { return s ? JSON.parse(s) : false; }); - const { status, send, sendCombo } = useRemoteConnection(); + const { status, latency, send, sendCombo } = useRemoteConnection(); // Pass sensitivity and invertScroll to the gesture hook const { isTracking, handlers } = useTrackpadGesture(send, scrollMode, sensitivity, invertScroll); @@ -172,6 +172,7 @@ function TrackpadPage() { scrollMode={scrollMode} modifier={modifier} buffer={buffer.join(" + ")} + latency={latency} onToggleScroll={() => setScrollMode(!scrollMode)} onLeftClick={() => handleClick('left')} onRightClick={() => handleClick('right')} diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 5c35971..a669a53 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -58,7 +58,10 @@ export function createWsServer(server: Server) { ws.send(JSON.stringify({ type: 'server-ip', ip: LAN_IP })); return; } - + if (msg.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong', timestamp: msg.timestamp })); + return; + } if (msg.type === 'update-config') { console.log('Updating config:', msg.config); try { diff --git a/src/types.tsx b/src/types.tsx index 621cc80..3786546 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -1 +1,10 @@ export type ModifierState = "Active" | "Release" | "Hold"; + +export type RemoteMessage = + | { type: "move"; dx: number; dy: number } + | { type: "scroll"; dx?: number; dy?: number } + | { type: "click"; button: "left" | "right" | "middle"; press: boolean } + | { type: "key"; key: string } + | { type: "text"; text: string } + | { type: "zoom"; delta: number } + | { type: "combo"; keys: string[] };