feat(ws): implement WebSocket context provider and update components to use it
This commit is contained in:
parent
fe2b677bc3
commit
91f6f5366d
|
|
@ -14,6 +14,7 @@ import RealityOverridePage from './components/RealityOverridePage';
|
||||||
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
|
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
|
||||||
import logoSvg from '../imports/logo.svg';
|
import logoSvg from '../imports/logo.svg';
|
||||||
import { useSettings } from './settings';
|
import { useSettings } from './settings';
|
||||||
|
import { WsProvider } from './ws';
|
||||||
|
|
||||||
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
||||||
|
|
||||||
|
|
@ -203,6 +204,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WsProvider>
|
||||||
<div className="size-full flex flex-col bg-neutral-50">
|
<div className="size-full flex flex-col bg-neutral-50">
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
<header className="bg-[#4d4d4d] px-0 py-0 flex-shrink-0" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
|
<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>
|
</div>
|
||||||
|
</WsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import { useWs } from '../ws';
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Loader2, Wifi, WifiOff, Send,
|
ChevronLeft, Loader2, Radio, WifiOff, Send,
|
||||||
Image as ImageIcon, Film, Music,
|
Image as ImageIcon, Film, Music,
|
||||||
Crop, RotateCcw, Minimize2,
|
Crop, RotateCcw, Minimize2,
|
||||||
Scissors, FileOutput, LayoutGrid,
|
Scissors, FileOutput, LayoutGrid,
|
||||||
|
|
@ -89,37 +90,17 @@ const GROUP_HEAD: Record<string, string> = {
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) {
|
export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
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 [lastSent, setLastSent] = useState<string | null>(null);
|
||||||
const [flashId, setFlashId] = useState(0);
|
const [flashId, setFlashId] = useState(0);
|
||||||
const [freeform, setFreeform] = useState('');
|
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 ────────────────────────────────────────────────────────────
|
// ── Send command ────────────────────────────────────────────────────────────
|
||||||
const send = (payload: string) => {
|
const send = (payload: string) => {
|
||||||
if (wsRef.current?.readyState !== WebSocket.OPEN) return;
|
if (wsStatus !== 'connected') return;
|
||||||
wsRef.current.send(payload);
|
wsSend(payload);
|
||||||
setLastSent(payload);
|
setLastSent(payload);
|
||||||
setFlashId(n => n + 1);
|
setFlashId(n => n + 1);
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -153,7 +134,7 @@ export default function RealityOverrideAdminPage({ onBack }: { onBack: () => voi
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<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 === '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></>}
|
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Offline</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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 * as THREE from 'three';
|
||||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const starCanvasRef = useRef<HTMLCanvasElement>(null);
|
const starCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const starsRef = useRef<Star[]>([]);
|
const starsRef = useRef<Star[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const msgIdRef = useRef(0);
|
const msgIdRef = useRef(0);
|
||||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const pausedRef = useRef(localStorage.getItem('ro-bg') === 'off');
|
const pausedRef = useRef(localStorage.getItem('ro-bg') === 'off');
|
||||||
const lastTapRef = useRef(0);
|
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 [currentCmd, setCurrentCmd] = useState<Msg | null>(null);
|
||||||
const [history, setHistory] = useState<Msg[]>([]);
|
const [history, setHistory] = useState<Msg[]>([]);
|
||||||
const [bgVisible, setBgVisible] = useState(() => localStorage.getItem('ro-bg') !== 'off');
|
const [bgVisible, setBgVisible] = useState(() => localStorage.getItem('ro-bg') !== 'off');
|
||||||
|
|
@ -289,33 +289,17 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
|
||||||
return () => cancelAnimationFrame(animId);
|
return () => cancelAnimationFrame(animId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── WebSocket ───────────────────────────────────────────────────────────────
|
// ── WebSocket (shared connection via context) ───────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
return subscribe((data) => {
|
||||||
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 id = msgIdRef.current++;
|
||||||
const msg: Msg = { text: String(e.data), id };
|
const msg: Msg = { text: data, id };
|
||||||
setCurrentCmd(msg);
|
setCurrentCmd(msg);
|
||||||
setHistory(prev => [...prev.slice(-9), msg]);
|
setHistory(prev => [...prev.slice(-9), msg]);
|
||||||
clearTimeout(fadeTimer.current);
|
clearTimeout(fadeTimer.current);
|
||||||
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
|
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
|
||||||
};
|
});
|
||||||
ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } };
|
}, [subscribe]);
|
||||||
ws.onerror = () => ws?.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
return () => { cancelled = true; ws?.close(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -351,7 +335,7 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
|
||||||
{/* WS status */}
|
{/* 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">
|
<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 === '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></>}
|
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Reconnecting…</span></>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from 'react';
|
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 DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||||
import MediaSet from './MediaSet';
|
import MediaSet from './MediaSet';
|
||||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
|
|
@ -13,6 +14,8 @@ interface StatusPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
|
const { status: wsStatus } = useWs();
|
||||||
|
|
||||||
// Mock memory stats
|
// Mock memory stats
|
||||||
const memory = {
|
const memory = {
|
||||||
heap: { total: 4096, free: 1024 }, // in KB
|
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-sm text-neutral-700">192.168.1.100</div>
|
||||||
<div className="text-xs text-neutral-500 mt-1">MAC Address</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-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>
|
<div>
|
||||||
<div className="text-xs text-neutral-500">Uptime</div>
|
<div className="text-xs text-neutral-500">Uptime</div>
|
||||||
|
|
|
||||||
57
src/app/ws.tsx
Normal file
57
src/app/ws.tsx
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user