feat(canvas): header camera toggles for component-owned webcams

Boards with a camera surface their webcam state in the header
(CameraToggle); components that ARE cameras — vision sensors whose
element captures the webcam itself — had no header presence, so users
who learned "the header icon says my webcam is in use" read its absence
as a broken feature. A tiny registry seam lets any component register
its camera (label, live/error/off, reason, toggle); the header renders
one CameraToggle-styled button per entry and nothing when none.
This commit is contained in:
David Montero Crespo 2026-08-07 21:11:33 +02:00
parent 93c5c60d9c
commit 704fd6ef32
3 changed files with 133 additions and 0 deletions

View File

@ -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 (
<button
onClick={cam.toggle}
title={tooltip}
style={{
backgroundColor: isOn ? 'rgba(63,185,80,0.15)' : 'transparent',
border: `1px solid ${isOn ? '#3fb950' : 'transparent'}`,
borderRadius: 4,
padding: '4px 10px',
display: 'flex',
alignItems: 'center',
gap: 6,
color,
fontSize: 13,
cursor: 'pointer',
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span>{cam.label}</span>
</button>
);
};
export const ComponentCameraToggles: React.FC = () => {
const cams = useComponentCameras();
if (cams.length === 0) return null;
return (
<>
{cams.map(([id, cam]) => (
<Toggle key={id} cam={cam} />
))}
</>
);
};

View File

@ -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. */}
<ComponentCameraToggles />
{/* 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

View File

@ -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<string, ComponentCameraEntry>();
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);
}