diff --git a/frontend/src/components/simulator/ComponentCameraToggles.tsx b/frontend/src/components/simulator/ComponentCameraToggles.tsx new file mode 100644 index 00000000..cba9f32e --- /dev/null +++ b/frontend/src/components/simulator/ComponentCameraToggles.tsx @@ -0,0 +1,67 @@ +/** + * Header toggles for component-owned webcams (see componentCameraRegistry). + * + * One button per registered component camera, drawn like CameraToggle so the + * header speaks one language: green while the webcam feeds the component, + * red with the reason in the tooltip when it could not, grey when off. + * Renders nothing for the common project with no such component. + */ +import React from 'react'; +import { useComponentCameras } from '../../lib/componentCameraRegistry'; +import type { ComponentCameraEntry } from '../../lib/componentCameraRegistry'; + +const Toggle: React.FC<{ cam: ComponentCameraEntry }> = ({ cam }) => { + const isOn = cam.status === 'live'; + const color = isOn ? '#3fb950' : cam.status === 'error' ? '#f85149' : '#ccc'; + const tooltip = + cam.status === 'live' + ? `${cam.label}: your webcam is on — click to stop` + : cam.status === 'error' + ? `${cam.label}: ${cam.reason ?? 'camera failed'} — click to retry` + : `${cam.label}: click to use your webcam`; + return ( + + ); +}; + +export const ComponentCameraToggles: React.FC = () => { + const cams = useComponentCameras(); + if (cams.length === 0) return null; + return ( + <> + {cams.map(([id, cam]) => ( + + ))} + + ); +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index c7fd0a5b..60f0ab0b 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -21,6 +21,7 @@ import { InstrumentComponent } from '../components-instruments/InstrumentCompone import { ComponentRegistry } from '../../services/ComponentRegistry'; import { getTabSessionId } from '../../simulation/Esp32Bridge'; import { CameraToggle } from './CameraToggle'; +import { ComponentCameraToggles } from './ComponentCameraToggles'; import { MicrophoneToggle } from './MicrophoneToggle'; import { WireLayer } from './WireLayer'; import type { SegmentHandle, WaypointHandle, AlignmentGuide } from './WireLayer'; @@ -2718,6 +2719,11 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { /> )} + {/* Header toggles for COMPONENT-owned webcams (vision sensors + that capture the webcam themselves — see + componentCameraRegistry). Renders nothing when none. */} + + {/* Microphone stream toggle, for boards whose def declares an on-board mic (their bridge implements setMicrophoneSource). Off = the bridge's 440 Hz test tone keeps mic sketches diff --git a/frontend/src/lib/componentCameraRegistry.ts b/frontend/src/lib/componentCameraRegistry.ts new file mode 100644 index 00000000..47bd3182 --- /dev/null +++ b/frontend/src/lib/componentCameraRegistry.ts @@ -0,0 +1,60 @@ +/** + * Header indicator for components that own a webcam. + * + * Boards with a camera get the header CameraToggle, but that path is + * board-plumbing (frames into the guest's DVP). Some COMPONENTS are cameras + * too — vision sensors whose element captures the webcam itself. Without a + * header presence their camera state lives only in the tiny art on the + * canvas, and a user who has learned "the header icon tells me when my + * webcam is in use" reads its absence as "the camera is not working". + * + * A component that runs a webcam registers here on attach and keeps its + * entry updated; the header renders one toggle per entry, styled like + * CameraToggle (green live, red failed with the reason, grey off). The + * registry is a seam: OSS renders whatever registered, overlays register. + */ +import { useSyncExternalStore } from 'react'; + +export interface ComponentCameraEntry { + /** Short button label, e.g. "Gesture cam". */ + label: string; + status: 'off' | 'live' | 'error'; + /** Why the camera is not running, shown in the tooltip when status=error. */ + reason?: string; + /** Click handler: switch the component's camera on/off (or retry). */ + toggle: () => void; +} + +const entries = new Map(); +const listeners = new Set<() => void>(); +// useSyncExternalStore wants a STABLE snapshot between changes. +let snapshot: Array<[string, ComponentCameraEntry]> = []; + +function emit(): void { + snapshot = Array.from(entries.entries()); + listeners.forEach((l) => l()); +} + +/** Add or update a component's camera entry (keyed by component id). */ +export function setComponentCamera(id: string, entry: ComponentCameraEntry): void { + entries.set(id, entry); + emit(); +} + +export function removeComponentCamera(id: string): void { + if (entries.delete(id)) emit(); +} + +function subscribe(l: () => void): () => void { + listeners.add(l); + return () => listeners.delete(l); +} + +function getSnapshot(): Array<[string, ComponentCameraEntry]> { + return snapshot; +} + +/** All registered component cameras, newest last. Empty for most projects. */ +export function useComponentCameras(): Array<[string, ComponentCameraEntry]> { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}