import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react'; import { api, getAuthToken } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { ChamberLight } from '../components/icons/ChamberLight'; import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal'; const MAX_RECONNECT_ATTEMPTS = 5; const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds const MAX_RECONNECT_DELAY = 30000; // 30 seconds const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds export function CameraPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const { printerId } = useParams<{ printerId: string }>(); const id = parseInt(printerId || '0', 10); const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream'); const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false); const [streamError, setStreamError] = useState(false); const [streamLoading, setStreamLoading] = useState(true); const [imageKey, setImageKey] = useState(Date.now()); const [transitioning, setTransitioning] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [reconnectAttempts, setReconnectAttempts] = useState(0); const [isReconnecting, setIsReconnecting] = useState(false); const [reconnectCountdown, setReconnectCountdown] = useState(0); const [zoomLevel, setZoomLevel] = useState(1); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [lastTouchDistance, setLastTouchDistance] = useState(null); const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null); const imgRef = useRef(null); const containerRef = useRef(null); const reconnectTimerRef = useRef(null); const countdownIntervalRef = useRef(null); const stallCheckIntervalRef = useRef(null); // Fetch printer info for the title const { data: printer } = useQuery({ queryKey: ['printer', id], queryFn: () => api.getPrinter(id), enabled: id > 0, }); // Fetch printer status for light toggle and skip objects const { data: status } = useQuery({ queryKey: ['printerStatus', id], queryFn: () => api.getPrinterStatus(id), refetchInterval: 30000, enabled: id > 0, }); // Chamber light mutation with optimistic update const chamberLightMutation = useMutation({ mutationFn: (on: boolean) => api.setChamberLight(id, on), onMutate: async (on) => { await queryClient.cancelQueries({ queryKey: ['printerStatus', id] }); const previousStatus = queryClient.getQueryData(['printerStatus', id]); queryClient.setQueryData(['printerStatus', id], (old: typeof status) => ({ ...old, chamber_light: on, })); return { previousStatus }; }, onSuccess: (_, on) => { showToast(`Chamber light ${on ? 'on' : 'off'}`); }, onError: (error: Error, _, context) => { if (context?.previousStatus) { queryClient.setQueryData(['printerStatus', id], context.previousStatus); } showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error'); }, }); const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2; // Update document title useEffect(() => { if (printer) { document.title = `${printer.name} - Camera`; } return () => { document.title = 'Bambuddy'; }; }, [printer]); // Cleanup on unmount - stop the camera stream // Track if we've already sent the stop signal to avoid duplicate calls const stopSentRef = useRef(false); useEffect(() => { const stopUrl = `/api/v1/printers/${id}/camera/stop`; stopSentRef.current = false; const sendStopOnce = () => { if (id > 0 && !stopSentRef.current) { stopSentRef.current = true; const headers: Record = {}; const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {}); } }; // Handle page unload/close with keepalive fetch (more reliable than sendBeacon, supports auth) const handleBeforeUnload = () => { sendStopOnce(); }; window.addEventListener('beforeunload', handleBeforeUnload); // Store ref value for cleanup - ref may change by cleanup time const imgElement = imgRef.current; return () => { window.removeEventListener('beforeunload', handleBeforeUnload); // Clear the image source first to stop the stream if (imgElement) { imgElement.src = ''; } // Send stop signal only once sendStopOnce(); }; }, [id]); // Auto-hide loading after timeout useEffect(() => { if (streamLoading && !transitioning) { const timeout = streamMode === 'stream' ? 3000 : 20000; const timer = setTimeout(() => { setStreamLoading(false); }, timeout); return () => clearTimeout(timer); } }, [streamMode, streamLoading, imageKey, transitioning]); // Fullscreen change listener - refresh stream after fullscreen transition useEffect(() => { const handleFullscreenChange = () => { const nowFullscreen = !!document.fullscreenElement; setIsFullscreen(nowFullscreen); // Reset zoom on fullscreen transition setZoomLevel(1); setPanOffset({ x: 0, y: 0 }); // Refresh stream after fullscreen transition to prevent stall if (streamMode === 'stream' && !transitioning) { // Clear image src first, then set new key after delay if (imgRef.current) { imgRef.current.src = ''; } setTimeout(() => { setStreamLoading(true); setImageKey(Date.now()); }, 200); } }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, [streamMode, transitioning]); // Save window size and position when user resizes or moves // Works for both popup windows and standalone camera pages useEffect(() => { let saveTimeout: NodeJS.Timeout; const saveWindowState = () => { // Debounce to avoid saving during drag clearTimeout(saveTimeout); saveTimeout = setTimeout(() => { localStorage.setItem('cameraWindowState', JSON.stringify({ width: window.outerWidth, height: window.outerHeight, left: window.screenX, top: window.screenY, })); }, 500); }; window.addEventListener('resize', saveWindowState); return () => { clearTimeout(saveTimeout); window.removeEventListener('resize', saveWindowState); }; }, []); // Clean up reconnect timers on unmount useEffect(() => { return () => { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); } if (stallCheckIntervalRef.current) { clearInterval(stallCheckIntervalRef.current); } }; }, []); // Auto-reconnect logic const attemptReconnect = useCallback(() => { if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { setIsReconnecting(false); setStreamError(true); return; } // Calculate delay with exponential backoff const delay = Math.min( INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY ); setIsReconnecting(true); setReconnectCountdown(Math.ceil(delay / 1000)); // Countdown timer countdownIntervalRef.current = setInterval(() => { setReconnectCountdown((prev) => { if (prev <= 1) { if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); } return 0; } return prev - 1; }); }, 1000); // Reconnect after delay reconnectTimerRef.current = setTimeout(() => { setReconnectAttempts((prev) => prev + 1); setIsReconnecting(false); setStreamLoading(true); setStreamError(false); if (imgRef.current) { imgRef.current.src = ''; } setImageKey(Date.now()); }, delay); }, [reconnectAttempts]); // Stall detection - periodically check if stream is still receiving frames useEffect(() => { // Only skip stall check during initial load, reconnecting, or transitioning // Continue checking even during streamError to detect recovery if (streamMode !== 'stream' || streamLoading || isReconnecting || transitioning) { if (stallCheckIntervalRef.current) { clearInterval(stallCheckIntervalRef.current); stallCheckIntervalRef.current = null; } return; } // Start stall detection after stream has loaded stallCheckIntervalRef.current = setInterval(async () => { try { const status = await api.getCameraStatus(id); // Trigger reconnect if: // 1. Backend reports stall (no frames for 10+ seconds) // 2. OR stream is not active anymore (process died) if (status.stalled || (!status.active && !streamError)) { console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`); if (stallCheckIntervalRef.current) { clearInterval(stallCheckIntervalRef.current); stallCheckIntervalRef.current = null; } setStreamLoading(false); attemptReconnect(); } } catch { // Ignore fetch errors - server might be temporarily unavailable } }, STALL_CHECK_INTERVAL); return () => { if (stallCheckIntervalRef.current) { clearInterval(stallCheckIntervalRef.current); stallCheckIntervalRef.current = null; } }; }, [streamMode, streamLoading, streamError, isReconnecting, transitioning, id, attemptReconnect]); const handleStreamError = () => { setStreamLoading(false); // Only auto-reconnect for live stream mode if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { attemptReconnect(); } else { setStreamError(true); } }; const handleStreamLoad = () => { setStreamLoading(false); setStreamError(false); // Reset reconnect attempts on successful connection setReconnectAttempts(0); setIsReconnecting(false); if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); } // Auto-resize window to fit video content (only if no saved preference) if (imgRef.current && !localStorage.getItem('cameraWindowState')) { const img = imgRef.current; const videoWidth = img.naturalWidth; const videoHeight = img.naturalHeight; if (videoWidth > 0 && videoHeight > 0) { // Add space for header bar (~45px) and some padding const headerHeight = 45; const padding = 16; // Calculate window size (outer size includes chrome) const chromeWidth = window.outerWidth - window.innerWidth; const chromeHeight = window.outerHeight - window.innerHeight; const targetWidth = videoWidth + padding + chromeWidth; const targetHeight = videoHeight + headerHeight + padding + chromeHeight; try { window.resizeTo(targetWidth, targetHeight); } catch { // resizeTo may not be allowed in all contexts } } } }; const stopStream = () => { if (id > 0) { const headers: Record = {}; const token = getAuthToken(); if (token) headers['Authorization'] = `Bearer ${token}`; fetch(`/api/v1/printers/${id}/camera/stop`, { method: 'POST', headers }).catch(() => {}); } }; const switchToMode = (newMode: 'stream' | 'snapshot') => { if (streamMode === newMode || transitioning) return; setTransitioning(true); setStreamLoading(true); setStreamError(false); // Reset reconnect state on mode switch setReconnectAttempts(0); setIsReconnecting(false); // Reset zoom on mode switch setZoomLevel(1); setPanOffset({ x: 0, y: 0 }); if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); } if (imgRef.current) { imgRef.current.src = ''; } // Stop any active streams when switching modes if (streamMode === 'stream') { stopStream(); } setTimeout(() => { setStreamMode(newMode); setImageKey(Date.now()); setTransitioning(false); }, 100); }; const refresh = () => { if (transitioning) return; setTransitioning(true); setStreamLoading(true); setStreamError(false); // Reset reconnect state on manual refresh setReconnectAttempts(0); setIsReconnecting(false); if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); } if (imgRef.current) { imgRef.current.src = ''; } // Stop any active streams before refresh if (streamMode === 'stream') { stopStream(); } setTimeout(() => { setImageKey(Date.now()); setTransitioning(false); }, 100); }; const toggleFullscreen = () => { if (!containerRef.current) return; if (document.fullscreenElement) { document.exitFullscreen(); } else { containerRef.current.requestFullscreen(); } }; const handleZoomIn = () => { setZoomLevel(prev => Math.min(prev + 0.5, 4)); }; const handleZoomOut = () => { setZoomLevel(prev => { const newZoom = Math.max(prev - 0.5, 1); if (newZoom === 1) setPanOffset({ x: 0, y: 0 }); return newZoom; }); }; const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); if (e.deltaY < 0) { handleZoomIn(); } else { handleZoomOut(); } }; const handleImageMouseDown = (e: React.MouseEvent) => { if (zoomLevel > 1) { e.preventDefault(); setIsPanning(true); setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y }); } }; // Calculate max pan based on container size and zoom level const getMaxPan = useCallback(() => { if (!containerRef.current) { return { x: 300, y: 200 }; } const container = containerRef.current.getBoundingClientRect(); // Allow panning up to half the zoomed overflow in each direction const maxX = (container.width * (zoomLevel - 1)) / 2; const maxY = (container.height * (zoomLevel - 1)) / 2; return { x: Math.max(50, maxX), y: Math.max(50, maxY) }; }, [zoomLevel]); const handleImageMouseMove = (e: React.MouseEvent) => { if (isPanning && zoomLevel > 1) { const newX = e.clientX - panStart.x; const newY = e.clientY - panStart.y; const maxPan = getMaxPan(); setPanOffset({ x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)), y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)), }); } }; const handleImageMouseUp = () => { setIsPanning(false); }; // Touch event handlers for mobile const getTouchDistance = (touches: React.TouchList) => { if (touches.length < 2) return 0; const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.sqrt(dx * dx + dy * dy); }; const getTouchCenter = (touches: React.TouchList) => { if (touches.length < 2) { return { x: touches[0].clientX, y: touches[0].clientY }; } return { x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2, }; }; const handleTouchStart = (e: React.TouchEvent) => { if (e.touches.length === 2) { // Pinch gesture start e.preventDefault(); setLastTouchDistance(getTouchDistance(e.touches)); setLastTouchCenter(getTouchCenter(e.touches)); } else if (e.touches.length === 1 && zoomLevel > 1) { // Single touch pan start e.preventDefault(); setIsPanning(true); setPanStart({ x: e.touches[0].clientX - panOffset.x, y: e.touches[0].clientY - panOffset.y, }); } }; const handleTouchMove = (e: React.TouchEvent) => { if (e.touches.length === 2 && lastTouchDistance !== null) { // Pinch gesture e.preventDefault(); const newDistance = getTouchDistance(e.touches); const scale = newDistance / lastTouchDistance; setZoomLevel(prev => { const newZoom = Math.max(1, Math.min(4, prev * scale)); if (newZoom === 1) { setPanOffset({ x: 0, y: 0 }); } return newZoom; }); setLastTouchDistance(newDistance); // Also handle pan during pinch const newCenter = getTouchCenter(e.touches); if (lastTouchCenter) { const maxPan = getMaxPan(); setPanOffset(prev => ({ x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))), y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))), })); } setLastTouchCenter(newCenter); } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) { // Single touch pan e.preventDefault(); const newX = e.touches[0].clientX - panStart.x; const newY = e.touches[0].clientY - panStart.y; const maxPan = getMaxPan(); setPanOffset({ x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)), y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)), }); } }; const handleTouchEnd = (e: React.TouchEvent) => { if (e.touches.length < 2) { setLastTouchDistance(null); setLastTouchCenter(null); } if (e.touches.length === 0) { setIsPanning(false); } }; const resetZoom = () => { setZoomLevel(1); setPanOffset({ x: 0, y: 0 }); }; const currentUrl = transitioning ? '' : streamMode === 'stream' ? `/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}` : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`; const isDisabled = streamLoading || transitioning || isReconnecting; if (!id) { return (

{t('camera.invalidPrinterId')}

); } return (
{/* Header */}

{printer?.name || `Printer ${id}`}

{/* Mode toggle */}
{/* Video area */}
{(streamLoading || transitioning) && !isReconnecting && (

{streamMode === 'stream' ? t('camera.connectingToCamera') : t('camera.capturingSnapshot')}

)} {isReconnecting && (

{t('camera.connectionLost')}

{t('camera.reconnecting', { countdown: reconnectCountdown, attempt: reconnectAttempts + 1, max: MAX_RECONNECT_ATTEMPTS })}

)} {streamError && !isReconnecting && (

{t('camera.cameraUnavailable')}

{t('camera.cameraUnavailableDesc')}

)} {t('camera.cameraStream')} 1 ? (isPanning ? 'grabbing' : 'grab') : 'default', }} onError={currentUrl ? handleStreamError : undefined} onLoad={currentUrl ? handleStreamLoad : undefined} onMouseDown={handleImageMouseDown} draggable={false} /> {/* Zoom controls */}
{/* Skip Objects Modal */} setShowSkipObjectsModal(false)} />
); }