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:
parent
93c5c60d9c
commit
704fd6ef32
|
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue