99 lines
2.9 KiB
TypeScript
99 lines
2.9 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';
|
||
|
|
|
||
|
|
interface CameraToggleProps {
|
||
|
|
boardId: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const CameraToggle: React.FC<CameraToggleProps> = ({ boardId }) => {
|
||
|
|
const { status, errorMessage, framesSent, lastFrameBytes, start, stop } =
|
||
|
|
useWebcamFrames();
|
||
|
|
|
||
|
|
const handleClick = () => {
|
||
|
|
if (!boardId) return;
|
||
|
|
if (status === 'streaming') {
|
||
|
|
stop();
|
||
|
|
} else {
|
||
|
|
void start(boardId);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const isOn = status === 'streaming';
|
||
|
|
const tooltip =
|
||
|
|
status === 'streaming'
|
||
|
|
? `Streaming webcam (${framesSent} frames, last=${(lastFrameBytes / 1024).toFixed(1)} KB) — click to stop`
|
||
|
|
: 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}
|
||
|
|
style={{
|
||
|
|
background: isOn ? 'rgba(63,185,80,0.15)' : 'transparent',
|
||
|
|
border: `1px solid ${isOn ? '#3fb950' : '#555'}`,
|
||
|
|
borderRadius: 4,
|
||
|
|
padding: '4px 10px',
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
gap: 6,
|
||
|
|
color,
|
||
|
|
fontSize: 13,
|
||
|
|
cursor: boardId ? 'pointer' : 'not-allowed',
|
||
|
|
animation:
|
||
|
|
status === 'requesting'
|
||
|
|
? 'velxio-pulse 1s ease-in-out infinite'
|
||
|
|
: 'none',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
};
|