diff --git a/src/app/App.tsx b/src/app/App.tsx index 2c89e57..bbf408d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,6 +14,7 @@ import RealityOverridePage from './components/RealityOverridePage'; import RealityOverrideAdminPage from './components/RealityOverrideAdminPage'; import logoSvg from '../imports/logo.svg'; import { useSettings } from './settings'; +import { WsProvider } from './ws'; type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId; @@ -203,6 +204,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) { } return ( +
@@ -342,6 +344,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) { /> )}
+
); } diff --git a/src/app/components/RealityOverrideAdminPage.tsx b/src/app/components/RealityOverrideAdminPage.tsx index 9ab6323..c3e72ef 100644 --- a/src/app/components/RealityOverrideAdminPage.tsx +++ b/src/app/components/RealityOverrideAdminPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; +import { useWs } from '../ws'; import { - ChevronLeft, Loader2, Wifi, WifiOff, Send, + ChevronLeft, Loader2, Radio, WifiOff, Send, Image as ImageIcon, Film, Music, Crop, RotateCcw, Minimize2, Scissors, FileOutput, LayoutGrid, @@ -89,37 +90,17 @@ const GROUP_HEAD: Record = { // ── Component ───────────────────────────────────────────────────────────────── export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) { - const wsRef = useRef(null); const timerRef = useRef | undefined>(undefined); - const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const { status: wsStatus, send: wsSend } = useWs(); const [lastSent, setLastSent] = useState(null); const [flashId, setFlashId] = useState(0); const [freeform, setFreeform] = useState(''); - // ── WebSocket ─────────────────────────────────────────────────────────────── - useEffect(() => { - let cancelled = false; - let ws: WebSocket | null = null; - - const connect = () => { - if (cancelled) return; - ws = new WebSocket(`ws://${window.location.hostname}/ws`); - wsRef.current = ws; - setWsStatus('connecting'); - ws.onopen = () => { if (!cancelled) setWsStatus('connected'); }; - ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } }; - ws.onerror = () => ws?.close(); - }; - - connect(); - return () => { cancelled = true; ws?.close(); }; - }, []); - // ── Send command ──────────────────────────────────────────────────────────── const send = (payload: string) => { - if (wsRef.current?.readyState !== WebSocket.OPEN) return; - wsRef.current.send(payload); + if (wsStatus !== 'connected') return; + wsSend(payload); setLastSent(payload); setFlashId(n => n + 1); clearTimeout(timerRef.current); @@ -153,7 +134,7 @@ export default function RealityOverrideAdminPage({ onBack }: { onBack: () => voi
{wsStatus === 'connecting' && <>Connecting…} - {wsStatus === 'connected' && <>Connected} + {wsStatus === 'connected' && <>Connected} {wsStatus === 'disconnected' && <>Offline}
diff --git a/src/app/components/RealityOverridePage.tsx b/src/app/components/RealityOverridePage.tsx index dec1015..a5bd6d1 100644 --- a/src/app/components/RealityOverridePage.tsx +++ b/src/app/components/RealityOverridePage.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { ChevronLeft, Loader2, Wifi, WifiOff } from 'lucide-react'; +import { ChevronLeft, Loader2, Radio, WifiOff } from 'lucide-react'; +import { useWs } from '../ws'; import * as THREE from 'three'; import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; @@ -97,13 +98,12 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void }) const containerRef = useRef(null); const starCanvasRef = useRef(null); const starsRef = useRef([]); - const wsRef = useRef(null); const msgIdRef = useRef(0); const fadeTimer = useRef | undefined>(undefined); const pausedRef = useRef(localStorage.getItem('ro-bg') === 'off'); const lastTapRef = useRef(0); - const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const { status: wsStatus, subscribe } = useWs(); const [currentCmd, setCurrentCmd] = useState(null); const [history, setHistory] = useState([]); const [bgVisible, setBgVisible] = useState(() => localStorage.getItem('ro-bg') !== 'off'); @@ -289,33 +289,17 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void }) return () => cancelAnimationFrame(animId); }, []); - // ── WebSocket ─────────────────────────────────────────────────────────────── + // ── WebSocket (shared connection via context) ─────────────────────────────── useEffect(() => { - let cancelled = false; - let ws: WebSocket | null = null; - - const connect = () => { - if (cancelled) return; - ws = new WebSocket(`ws://${window.location.hostname}/ws`); - wsRef.current = ws; - setWsStatus('connecting'); - ws.onopen = () => { if (!cancelled) setWsStatus('connected'); }; - ws.onmessage = (e) => { - if (cancelled) return; - const id = msgIdRef.current++; - const msg: Msg = { text: String(e.data), id }; - setCurrentCmd(msg); - setHistory(prev => [...prev.slice(-9), msg]); - clearTimeout(fadeTimer.current); - fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000); - }; - ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } }; - ws.onerror = () => ws?.close(); - }; - - connect(); - return () => { cancelled = true; ws?.close(); }; - }, []); + return subscribe((data) => { + const id = msgIdRef.current++; + const msg: Msg = { text: data, id }; + setCurrentCmd(msg); + setHistory(prev => [...prev.slice(-9), msg]); + clearTimeout(fadeTimer.current); + fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000); + }); + }, [subscribe]); return (
void }) {/* WS status */}
{wsStatus === 'connecting' && <>Connecting…} - {wsStatus === 'connected' && <>Connected} + {wsStatus === 'connected' && <>Connected} {wsStatus === 'disconnected' && <>Reconnecting…}
diff --git a/src/app/components/StatusPage.tsx b/src/app/components/StatusPage.tsx index 5d09b72..38337eb 100644 --- a/src/app/components/StatusPage.tsx +++ b/src/app/components/StatusPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; -import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react'; +import { HardDrive, Activity, Wifi, Radio, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react'; +import { useWs } from '../ws'; import DeviceDetailOverlay from './DeviceDetailOverlay'; import MediaSet from './MediaSet'; import { ImageWithFallback } from './figma/ImageWithFallback'; @@ -13,6 +14,8 @@ interface StatusPageProps { } export default function StatusPage({ config, setConfig }: StatusPageProps) { + const { status: wsStatus } = useWs(); + // Mock memory stats const memory = { heap: { total: 4096, free: 1024 }, // in KB @@ -444,6 +447,12 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
192.168.1.100
MAC Address
AA:BB:CC:DD:EE:FF
+
WebSocket
+
+ {wsStatus === 'connecting' && <>Connecting} + {wsStatus === 'connected' && <>Connected} + {wsStatus === 'disconnected' && <>Disconnected} +
Uptime
diff --git a/src/app/ws.tsx b/src/app/ws.tsx new file mode 100644 index 0000000..e0d7cac --- /dev/null +++ b/src/app/ws.tsx @@ -0,0 +1,57 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; + +export type WsStatus = 'connecting' | 'connected' | 'disconnected'; + +interface WsContextValue { + status: WsStatus; + send: (msg: string) => void; + subscribe: (handler: (msg: string) => void) => () => void; +} + +const WsContext = createContext({ + status: 'disconnected', + send: () => {}, + subscribe: () => () => {}, +}); + +export function WsProvider({ children }: { children: React.ReactNode }) { + const wsRef = useRef(null); + const listeners = useRef void>>(new Set()); + const [status, setStatus] = useState('connecting'); + + useEffect(() => { + let cancelled = false; + let ws: WebSocket | null = null; + + const connect = () => { + if (cancelled) return; + ws = new WebSocket(`ws://${window.location.hostname}/ws`); + wsRef.current = ws; + setStatus('connecting'); + ws.onopen = () => { if (!cancelled) setStatus('connected'); }; + ws.onmessage = (e) => { if (!cancelled) listeners.current.forEach(h => h(String(e.data))); }; + ws.onclose = () => { if (!cancelled) { setStatus('disconnected'); setTimeout(connect, 3000); } }; + ws.onerror = () => ws?.close(); + }; + + connect(); + return () => { cancelled = true; ws?.close(); }; + }, []); + + const send = useCallback((msg: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(msg); + }, []); + + const subscribe = useCallback((handler: (msg: string) => void) => { + listeners.current.add(handler); + return () => { listeners.current.delete(handler); }; + }, []); + + return ( + + {children} + + ); +} + +export const useWs = () => useContext(WsContext);