import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react'; import { getAuthToken, withStreamToken } from '../api/client'; import { formatDuration } from '../utils/date'; export type CameraTileMode = 'live' | 'snapshot' | 'paused'; export type CameraTileStatusMode = 'off' | 'compact' | 'full'; interface CameraTileProps { printerId: number; printerName: string; cameraRotation?: number; mode: CameraTileMode; snapshotIntervalMs: number; connected: boolean; onClick?: () => void; // Optional status overlay — wired by CameraWall from the shared // ['printerStatus', id] query. All optional so existing tests don't break. statusMode?: CameraTileStatusMode; printerState?: string | null; progress?: number | null; remainingMin?: number | null; layerNum?: number | null; totalLayers?: number | null; printName?: string | null; hmsErrorCount?: number; } // Tiles render lighter than EmbeddedCameraViewer's full window: lower fps, // no drag/resize/zoom shell, and snapshot fallback when off-cap. The server // still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire. const LIVE_FPS = 8; type StatusBucket = 'printing' | 'paused' | 'finished' | 'error' | 'idle'; function classifyState(state: string | null | undefined, hmsErrorCount: number): StatusBucket { if (hmsErrorCount > 0) return 'error'; switch (state) { case 'RUNNING': return 'printing'; case 'PAUSE': return 'paused'; case 'FINISH': case 'FAILED': return 'finished'; default: return 'idle'; } } const BUCKET_CHIP_CLASS: Record = { printing: 'bg-bambu-green/85 text-black', paused: 'bg-amber-500/85 text-black', finished: 'bg-sky-500/80 text-white', error: 'bg-red-500/85 text-white', idle: 'bg-bambu-dark-tertiary/80 text-bambu-gray', }; export function CameraTile({ printerId, printerName, cameraRotation = 0, mode, snapshotIntervalMs, connected, onClick, statusMode = 'off', printerState = null, progress = null, remainingMin = null, layerNum = null, totalLayers = null, printName = null, hmsErrorCount = 0, }: CameraTileProps) { const { t } = useTranslation(); const [bust, setBust] = useState(0); const [errored, setErrored] = useState(false); const lastModeRef = useRef(mode); // Tell the backend to release its MJPEG transcoder when this tile stops // being live — either by unmounting or by transitioning to snapshot/paused. // EmbeddedCameraViewer uses the same /camera/stop with keepalive on unmount. useEffect(() => { const wasLive = lastModeRef.current === 'live'; const isLive = mode === 'live'; lastModeRef.current = mode; if (wasLive && !isLive) { const headers: Record = {}; const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', keepalive: true, headers, }).catch(() => {}); } setErrored(false); setBust((b) => b + 1); }, [mode, printerId]); useEffect(() => { return () => { if (lastModeRef.current === 'live') { const headers: Record = {}; const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', keepalive: true, headers, }).catch(() => {}); } }; }, [printerId]); useEffect(() => { if (mode !== 'snapshot') return; const interval = setInterval(() => setBust((b) => b + 1), snapshotIntervalMs); return () => clearInterval(interval); }, [mode, snapshotIntervalMs]); const liveUrl = withStreamToken( `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`, ); const snapshotUrl = withStreamToken( `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`, ); // A kiosk wall passes no onClick — there is no pointer at a TV, and the page // is authenticated by a token that cannot open the single-camera view. Render // the tile as plain, non-focusable content rather than a button that looks // clickable and then does nothing. const interactive = onClick != null; const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined; const bucket = classifyState(printerState, hmsErrorCount); // Hide chip for idle to keep cold walls clean; always show when something // is happening (printing/paused/finished/error). const showChip = connected && statusMode !== 'off' && bucket !== 'idle'; const isPrintingOrPaused = bucket === 'printing' || bucket === 'paused'; const showInfoStrip = connected && statusMode === 'full' && isPrintingOrPaused; const fileLabel = printName ?? null; const progressPct = progress != null ? Math.round(progress) : null; const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0; const hasRemaining = remainingMin != null && remainingMin > 0; const rootClass = `group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left ${ interactive ? 'focus:outline-none focus:ring-2 focus:ring-bambu-green' : 'cursor-default' }`; const content = ( <> {!connected || mode === 'paused' ? (
{connected ? (
) : errored ? (
) : ( {printerName} setErrored(true)} /> )} {/* Status chip (top-left) */} {showChip && ( {hmsErrorCount > 0 && ( )} {/* Mode indicator (top-right) */} {mode === 'live' ? t('printers.camWall.live') : mode === 'snapshot' ? t('printers.camWall.snap') : t('printers.camWall.off')} {/* Bottom overlay: name + (when full) print info */}
{showInfoStrip && (
{fileLabel && (
{fileLabel}
)}
{progressPct != null && ( {progressPct}% )} {hasLayers && ( {t('printers.camWall.layer', { cur: layerNum, total: totalLayers, })} )} {hasRemaining && ( {t('printers.camWall.timeLeft', { time: formatDuration((remainingMin ?? 0) * 60), })} )}
)} {printerName}
); if (!interactive) { return (
{content}
); } return ( ); }