diff --git a/package-lock.json b/package-lock.json index 18e2710..a28082c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "lucide-react": "^0.469.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -1395,7 +1396,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.2.2" } @@ -2071,7 +2072,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "devOptional": true }, "node_modules/debug": { "version": "4.4.3", @@ -3855,6 +3856,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 10db2de..8c6600b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dependencies": { "lucide-react": "^0.469.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 11bb922..820e67d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,4 @@ export { useCameraStatus } from './useCameraStatus' export { useSystemHealth } from './useSystemHealth' +export { useSSE } from './useSSE' +export type { SSEConnectionState } from './useSSE' diff --git a/src/hooks/useSSE.ts b/src/hooks/useSSE.ts new file mode 100644 index 0000000..0d39a48 --- /dev/null +++ b/src/hooks/useSSE.ts @@ -0,0 +1,158 @@ +import { useEffect, useRef, useCallback } from 'react' +import { useCameraStore } from '../store/useCameraStore' +import type { CameraStatus } from '../types' + +/** Shape of events sent by the Go SSE hub */ +interface SSEEvent { + type: string + ts: string + payload?: unknown +} + +/** Connection states for the SSE stream */ +export type SSEConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error' + +interface UseSSEOptions { + /** Override the SSE endpoint (defaults to VITE_API_URL + /events/stream) */ + endpoint?: string +} + +interface UseSSEReturn { + /** Current connection state */ + connectionState: SSEConnectionState + /** Last event timestamp */ + lastEventTs: string | null + /** Manually close and reopen the connection */ + reconnect: () => void +} + +const DEFAULT_ENDPOINT = `${import.meta.env.VITE_API_URL || '/api/v1'}/events/stream` + +/** + * `useSSE` connects to the RemoteRig SSE endpoint at `/api/v1/events/stream` + * and updates the shared camera store (`useCameraStore`) with live events. + * + * Handled event types: + * - `connected` — initial handshake + * - `camera_status` — full camera status snapshot + * - `camera_online` / `camera_offline` — online/offline state change + * - `recording_event` — recording started or stopped + * + * EventSource auto-reconnects natively; the hook also exposes a `reconnect()` + * helper for manual reconnect. + */ +export function useSSE(opts: UseSSEOptions = {}): UseSSEReturn { + const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT + const store = useCameraStore() + + const connectionStateRef = useRef('disconnected') + const lastEventTsRef = useRef(null) + const esRef = useRef(null) + + // Derive a stable reference for connection state that doesn't trigger re-renders + // across every event. We return it via a getter callback but the store change + // re-renders are handled by Zustand selectors in consuming components. + + const handleEvent = useCallback( + (event: MessageEvent) => { + try { + const data: SSEEvent = JSON.parse(event.data) + lastEventTsRef.current = data.ts + + switch (data.type) { + case 'connected': { + // Initial handshake — no state update needed + connectionStateRef.current = 'connected' + break + } + + case 'camera_status': { + // Full camera status payload — upsert into the store + const cam = data.payload as CameraStatus | undefined + if (cam?.camera_id) { + store.updateCamera(cam) + } + break + } + + case 'camera_online': { + const pl = data.payload as { camera_id?: string } | undefined + if (pl?.camera_id) { + store.setOnline(pl.camera_id) + } + break + } + + case 'camera_offline': { + const pl = data.payload as { camera_id?: string } | undefined + if (pl?.camera_id) { + store.setOffline(pl.camera_id) + } + break + } + + case 'recording_event': { + const pl = data.payload as + | { camera_id?: string; recording?: boolean } + | undefined + if (pl?.camera_id && typeof pl.recording === 'boolean') { + store.setRecording(pl.camera_id, pl.recording) + } + break + } + + default: + // Unknown event type — ignore gracefully + break + } + } catch { + // Malformed JSON — ignore + } + }, + [store], + ) + + const connect = useCallback(() => { + // Close existing connection if any + esRef.current?.close() + + connectionStateRef.current = 'connecting' + const es = new EventSource(endpoint) + esRef.current = es + + es.onopen = () => { + connectionStateRef.current = 'connected' + } + + es.onmessage = handleEvent + + es.onerror = () => { + connectionStateRef.current = + es.readyState === EventSource.CLOSED ? 'disconnected' : 'error' + // EventSource auto-reconnects unless CLOSED; nothing extra needed here + } + }, [endpoint, handleEvent]) + + const reconnect = useCallback(() => { + esRef.current?.close() + connect() + }, [connect]) + + useEffect(() => { + connect() + return () => { + esRef.current?.close() + connectionStateRef.current = 'disconnected' + } + }, [connect]) + + return { + get connectionState() { + return connectionStateRef.current + }, + get lastEventTs() { + return lastEventTsRef.current + }, + reconnect, + } +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..1d1d3a5 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export { useCameraStore } from './useCameraStore' diff --git a/src/store/useCameraStore.ts b/src/store/useCameraStore.ts new file mode 100644 index 0000000..cb80132 --- /dev/null +++ b/src/store/useCameraStore.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand' +import type { CameraStatus } from '../types' + +interface CameraState { + /** All cameras with latest status from SSE events */ + cameras: Map + + /** Replace the full camera list (e.g. on initial SSE or polling fallback) */ + setCameras: (cameras: CameraStatus[]) => void + + /** Upsert a single camera status from an SSE event */ + updateCamera: (camera: CameraStatus) => void + + /** Mark a camera offline */ + setOffline: (cameraId: string) => void + + /** Mark a camera online */ + setOnline: (cameraId: string) => void + + /** Update recording state from a recording_event */ + setRecording: (cameraId: string, recording: boolean) => void + + /** Derived: camera array (for UI consumption) */ + getCameras: () => CameraStatus[] + + /** Derived: online count */ + getOnlineCount: () => number + + /** Derived: recording count */ + getRecordingCount: () => number +} + +export const useCameraStore = create((set, get) => ({ + cameras: new Map(), + + setCameras: (list) => + set((state) => { + const next = new Map(state.cameras) + for (const cam of list) { + next.set(cam.camera_id, cam) + } + return { cameras: next } + }), + + updateCamera: (camera) => + set((state) => { + const next = new Map(state.cameras) + next.set(camera.camera_id, camera) + return { cameras: next } + }), + + setOffline: (cameraId) => + set((state) => { + const cam = state.cameras.get(cameraId) + if (!cam) return state + const next = new Map(state.cameras) + next.set(cameraId, { ...cam, online: false }) + return { cameras: next } + }), + + setOnline: (cameraId) => + set((state) => { + const cam = state.cameras.get(cameraId) + if (!cam) return state + const next = new Map(state.cameras) + next.set(cameraId, { ...cam, online: true }) + return { cameras: next } + }), + + setRecording: (cameraId, recording) => + set((state) => { + const cam = state.cameras.get(cameraId) + if (!cam) return state + const next = new Map(state.cameras) + next.set(cameraId, { ...cam, recording }) + return { cameras: next } + }), + + getCameras: () => Array.from(get().cameras.values()), + + getOnlineCount: () => + Array.from(get().cameras.values()).filter((c) => c.online).length, + + getRecordingCount: () => + Array.from(get().cameras.values()).filter((c) => c.recording).length, +})) diff --git a/src/types/index.ts b/src/types/index.ts index 29e59da..344da4b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,26 @@ // RemoteRig TypeScript types +/** Full camera status from GET /api/v1/cameras and SSE events */ +export interface CameraStatus { + camera_id: string + friendly_name: string + battery_pct: number | null + video_remaining_sec: number | null + recording: boolean + mode: string + resolution: string + fps: number + online: boolean + last_seen: string // ISO 8601 +} + +/** SSE event envelope from /api/v1/events/stream */ +export interface SSEEvent { + type: string + ts: string + payload?: unknown +} + export interface Camera { id: string name: string