velxio/frontend/src/components/simulator/CameraToggle.tsx

159 lines
5.6 KiB
TypeScript

/**
* Camera button rendered on the canvas header for ESP32-CAM boards.
*
* Click → asks for webcam permission → starts streaming JPEG frames at
* 10 fps over the existing simulator WebSocket. The frames flow into
* the QEMU OV2640+I²S peripheral so user sketches that call
* `esp_camera_fb_get()` see real webcam content (subject to the
* upstream-driver caveats documented in test/test-esp32-cam/autosearch/10).
*
* Visual states:
* idle grey camera icon
* requesting orange (pulsing) — browser permission prompt visible
* streaming green + frame counter — frames flowing
* denied red, click-to-retry
* error red, hover for message
*/
import React from 'react';
import { useWebcamFrames } from '../../hooks/useWebcamFrames';
import { useSimulatorStore } from '../../store/useSimulatorStore';
interface CameraToggleProps {
boardId: string | null;
/** Per-board frame byte budget (see useWebcamFrames). Omit for the
* default QEMU ESP32-CAM bound. */
maxFrameBytes?: number;
}
export const CameraToggle: React.FC<CameraToggleProps> = ({ boardId, maxFrameBytes }) => {
const {
status,
errorMessage,
framesSent,
lastFrameBytes,
lastQualityUsed,
lastDownscaled,
start,
stop,
} = useWebcamFrames(maxFrameBytes);
// Auto-start when the sketch RUNS. An ESP32-CAM sketch exists to capture, so
// waiting for a click on this toggle just reads as "the camera is broken":
// the guest boots, esp_camera_init succeeds against the modelled OV2640, and
// then cam_hal times out forever waiting for frames nobody is sending. Ask
// for the webcam the moment the run starts - the browser's permission prompt
// is the user consent, so the toggle stays as the manual off-switch. Only
// once per run: stopping the stream by hand must not re-trigger it.
const running = useSimulatorStore((st) => st.running);
const autoStartedRef = React.useRef(false);
React.useEffect(() => {
if (!running) {
autoStartedRef.current = false;
return;
}
if (autoStartedRef.current || !boardId || status === 'streaming') return;
autoStartedRef.current = true;
void start(boardId);
}, [running, boardId, status, start]);
const handleClick = () => {
if (!boardId) return;
if (status === 'streaming') {
stop();
} else {
void start(boardId);
}
};
const isOn = status === 'streaming';
// Build a streaming tooltip that exposes the adaptive encoder state.
// Users see "auto-tuned" hints when the encoder had to drop quality
// or downscale — useful diagnostic for HD/4K webcams.
const streamingTooltip = () => {
const kb = (lastFrameBytes / 1024).toFixed(1);
const q = lastQualityUsed.toFixed(2);
const tuneNote = lastDownscaled
? ` (auto-downscaled, q=${q})`
: lastQualityUsed < 0.3
? ` (auto-tuned to q=${q})`
: ` (q=${q})`;
return `Streaming webcam (${framesSent} frames, last=${kb} KB${tuneNote}) — click to stop`;
};
const tooltip =
status === 'streaming'
? streamingTooltip()
: status === 'requesting'
? 'Asking for camera permission…'
: status === 'denied'
? `Permission denied. Click to retry.`
: status === 'error'
? `Webcam error: ${errorMessage ?? 'unknown'}`
: 'Use webcam as ESP32-CAM camera';
const color =
status === 'streaming'
? '#3fb950'
: status === 'requesting'
? '#f0883e'
: status === 'denied' || status === 'error'
? '#f85149'
: '#ccc';
return (
<button
onClick={handleClick}
disabled={!boardId || status === 'requesting'}
title={tooltip}
// Ants while the button wants a click DURING a run (idle after a
// failed/stopped auto-start, denied, error): a camera sketch is
// starving without frames. Never while the sim is stopped — the
// button is not asking for anything then. Solid border once
// streaming or asking.
// Static layout is in .canvas-cam-mic-btn so the toolbar @container
// query can collapse the label to icon-only (an inline font-size would
// beat it). Only state-driven styles stay inline below.
className={`canvas-cam-mic-btn${
running && boardId && status !== 'streaming' && status !== 'requesting'
? ' velxio-btn-ants'
: ''
}`}
style={{
// backgroundColor, NOT the background shorthand: inline shorthand
// would wipe the class's background-image and kill the ants.
backgroundColor: isOn ? 'rgba(63,185,80,0.15)' : 'transparent',
border: `1px solid ${
isOn ? '#3fb950' : status === 'requesting' ? '#555' : 'transparent'
}`,
color,
cursor: boardId ? 'pointer' : 'not-allowed',
// Inline only while requesting. 'none' here would OVERRIDE the
// .velxio-btn-ants class animation (inline beats class), which is
// exactly how the ants shipped dead the first time.
animation:
status === 'requesting'
? 'velxio-pulse 1s ease-in-out infinite'
: undefined,
}}
>
<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>
{isOn ? `Camera ${framesSent}` : 'Camera'}
</span>
</button>
);
};