feat(ws): implement WebSocket context provider and update components to use it

This commit is contained in:
Jaime Idolpx 2026-06-09 00:25:27 -04:00
parent fe2b677bc3
commit 91f6f5366d
5 changed files with 91 additions and 57 deletions

View File

@ -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 (
<WsProvider>
<div className="size-full flex flex-col bg-neutral-50">
<Toaster position="top-center" />
<header className="bg-[#4d4d4d] px-0 py-0 flex-shrink-0" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
@ -342,6 +344,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
/>
)}
</div>
</WsProvider>
);
}

View File

@ -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<string, string> = {
// ── Component ─────────────────────────────────────────────────────────────────
export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) {
const wsRef = useRef<WebSocket | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const { status: wsStatus, send: wsSend } = useWs();
const [lastSent, setLastSent] = useState<string | null>(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
</span>
<div className="flex items-center gap-1.5 text-xs">
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting</span></>}
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'connected' && <><Radio className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Offline</span></>}
</div>
</div>

View File

@ -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<HTMLDivElement>(null);
const starCanvasRef = useRef<HTMLCanvasElement>(null);
const starsRef = useRef<Star[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const msgIdRef = useRef(0);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | 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<Msg | null>(null);
const [history, setHistory] = useState<Msg[]>([]);
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;
return subscribe((data) => {
const id = msgIdRef.current++;
const msg: Msg = { text: String(e.data), id };
const msg: Msg = { text: 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(); };
}, []);
});
}, [subscribe]);
return (
<div
@ -351,7 +335,7 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
{/* WS status */}
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5 text-xs px-2 py-1 rounded bg-black/40 border border-white/10">
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting</span></>}
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'connected' && <><Radio className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Reconnecting</span></>}
</div>

View File

@ -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) {
<div className="text-sm text-neutral-700">192.168.1.100</div>
<div className="text-xs text-neutral-500 mt-1">MAC Address</div>
<div className="text-sm text-neutral-700">AA:BB:CC:DD:EE:FF</div>
<div className="text-xs text-neutral-500 mt-1">WebSocket</div>
<div className="flex items-center gap-1 text-sm">
{wsStatus === 'connecting' && <><Loader2 className="w-3 h-3 text-yellow-500 animate-spin" /><span className="text-yellow-600">Connecting</span></>}
{wsStatus === 'connected' && <><Radio className="w-3 h-3 text-green-600" /><span>Connected</span></>}
{wsStatus === 'disconnected' && <><Radio className="w-3 h-3 text-red-500" /><span className="text-red-600">Disconnected</span></>}
</div>
</div>
<div>
<div className="text-xs text-neutral-500">Uptime</div>

57
src/app/ws.tsx Normal file
View File

@ -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<WsContextValue>({
status: 'disconnected',
send: () => {},
subscribe: () => () => {},
});
export function WsProvider({ children }: { children: React.ReactNode }) {
const wsRef = useRef<WebSocket | null>(null);
const listeners = useRef<Set<(msg: string) => void>>(new Set());
const [status, setStatus] = useState<WsStatus>('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 (
<WsContext.Provider value={{ status, send, subscribe }}>
{children}
</WsContext.Provider>
);
}
export const useWs = () => useContext(WsContext);