// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 The Sargassum Training Kit Authors // Camera tab: resolution dropdown + remote shutter + film-strip of thumbnails (click -> full image). // The shutter POSTs a one-shot capture request; the board picks it up on its next detection POST, captures, // and uploads the JPEG. The strip polls /api/photos; image routes are unauthenticated so tags work. const CAM_RES = ['QVGA', 'VGA', 'HD', 'UXGA', 'FHD', '5MP']; function CameraTab({ drifter }) { const { useState, useEffect, useRef } = React; const [res, setRes] = useState('5MP'); const [photos, setPhotos] = useState([]); const [busy, setBusy] = useState(false); const [full, setFull] = useState(null); // photo id opened in the lightbox const lastCount = useRef(0); useEffect(() => { let stop = false; const tick = async () => { const p = await API.getPhotos(drifter); if (stop) return; setPhotos(p); if (busy && p.length > lastCount.current) setBusy(false); // our shot landed lastCount.current = p.length; }; tick(); const iv = setInterval(tick, 2000); return () => { stop = true; clearInterval(iv); }; }, [drifter, busy]); const shoot = async () => { setBusy(true); lastCount.current = photos.length; await API.postCaptureRequest(drifter, res); setTimeout(() => setBusy(false), 20000); // safety re-enable if no photo arrives }; return (
07 Camera remote shutter — capture over WiFi
{photos.length} frame(s)
{photos.length === 0 ?
No frames yet — hit the shutter. The board captures on its next ~1 s poll, then uploads.
:
{photos.map(p => (
p.ok && setFull(p.id)} style={{ flex: 'none', cursor: p.ok ? 'pointer' : 'default', textAlign: 'center' }}> {p.ok ? :
capture failed
}
{p.res || '—'}
))}
} {full != null && (
setFull(null)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'zoom-out' }}>
)}
); } window.CameraTab = CameraTab;