import { useState, useEffect, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react'; import { api, getAuthToken } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { ChamberLight } from './icons/ChamberLight'; import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal'; interface EmbeddedCameraViewerProps { printerId: number; printerName: string; viewerIndex?: number; // Used to offset multiple viewers onClose: () => void; } const STORAGE_KEY_PREFIX = 'embeddedCameraState_'; const MAX_RECONNECT_ATTEMPTS = 5; const INITIAL_RECONNECT_DELAY = 2000; const MAX_RECONNECT_DELAY = 30000; const STALL_CHECK_INTERVAL = 5000; interface CameraState { x: number; y: number; width: number; height: number; } const DEFAULT_STATE: CameraState = { x: window.innerWidth - 420, y: 20, width: 400, height: 300, }; export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); // Printer-specific storage key const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`; // Load saved state or use defaults (offset for new viewers without saved state) const loadState = (): CameraState => { try { const saved = localStorage.getItem(storageKey); if (saved) { const state = JSON.parse(saved); // Validate state is on screen return { x: Math.min(Math.max(0, state.x), window.innerWidth - 100), y: Math.min(Math.max(0, state.y), window.innerHeight - 100), width: Math.max(200, Math.min(state.width, window.innerWidth - 20)), height: Math.max(150, Math.min(state.height, window.innerHeight - 20)), }; } } catch { // Ignore parse errors } // Offset new viewers so they don't stack exactly on top of each other const offset = viewerIndex * 30; return { ...DEFAULT_STATE, x: Math.max(0, DEFAULT_STATE.x - offset), y: Math.max(0, DEFAULT_STATE.y + offset), }; }; const [state, setState] = useState(loadState); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [isMinimized, setIsMinimized] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); 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); // Stream state const [streamError, setStreamError] = useState(false); const [streamLoading, setStreamLoading] = useState(true); const [imageKey, setImageKey] = useState(Date.now()); const [reconnectAttempts, setReconnectAttempts] = useState(0); const [isReconnecting, setIsReconnecting] = useState(false); const [reconnectCountdown, setReconnectCountdown] = useState(0); const containerRef = useRef(null); const imgRef = useRef(null); const reconnectTimerRef = useRef(null); const countdownIntervalRef = useRef(null); const stallCheckIntervalRef = useRef(null); const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false); // Fetch printer info const { data: printer } = useQuery({ queryKey: ['printer', printerId], queryFn: () => api.getPrinter(printerId), enabled: printerId > 0, }); // Fetch printer status for light toggle and skip objects const { data: status } = useQuery({ queryKey: ['printerStatus', printerId], queryFn: () => api.getPrinterStatus(printerId), refetchInterval: 30000, enabled: printerId > 0, }); // Chamber light mutation with optimistic update const chamberLightMutation = useMutation({ mutationFn: (on: boolean) => api.setChamberLight(printerId, on), onMutate: async (on) => { await queryClient.cancelQueries({ queryKey: ['printerStatus', printerId] }); const previousStatus = queryClient.getQueryData(['printerStatus', printerId]); queryClient.setQueryData(['printerStatus', printerId], (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', printerId], context.previousStatus); } showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error'); }, }); const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2; // Save state to localStorage (printer-specific) useEffect(() => { const saveTimeout = setTimeout(() => { localStorage.setItem(storageKey, JSON.stringify(state)); }, 500); return () => clearTimeout(saveTimeout); }, [state, storageKey]); // Cleanup on unmount const stopSentRef = useRef(false); useEffect(() => { stopSentRef.current = false; const stopUrl = `/api/v1/printers/${printerId}/camera/stop`; const sendStopOnce = () => { if (printerId > 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(() => {}); } }; const imgElement = imgRef.current; return () => { if (imgElement) { imgElement.src = ''; } sendStopOnce(); if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); if (stallCheckIntervalRef.current) clearInterval(stallCheckIntervalRef.current); }; }, [printerId]); // Auto-hide loading after timeout useEffect(() => { if (streamLoading) { const timer = setTimeout(() => setStreamLoading(false), 3000); return () => clearTimeout(timer); } }, [streamLoading, imageKey]); // Auto-reconnect logic const attemptReconnect = useCallback(() => { if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { setIsReconnecting(false); setStreamError(true); return; } const delay = Math.min( INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY ); setIsReconnecting(true); setReconnectCountdown(Math.ceil(delay / 1000)); countdownIntervalRef.current = setInterval(() => { setReconnectCountdown((prev) => { if (prev <= 1) { if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); return 0; } return prev - 1; }); }, 1000); 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 useEffect(() => { if (streamLoading || isReconnecting || isMinimized) { if (stallCheckIntervalRef.current) { clearInterval(stallCheckIntervalRef.current); stallCheckIntervalRef.current = null; } return; } stallCheckIntervalRef.current = setInterval(async () => { try { const status = await api.getCameraStatus(printerId); if (status.stalled || (!status.active && !streamError)) { if (stallCheckIntervalRef.current) { clearInterval(stallCheckIntervalRef.current); stallCheckIntervalRef.current = null; } setStreamLoading(false); attemptReconnect(); } } catch { // Ignore errors } }, STALL_CHECK_INTERVAL); return () => { if (stallCheckIntervalRef.current) { clearInterval(stallCheckIntervalRef.current); stallCheckIntervalRef.current = null; } }; }, [streamLoading, streamError, isReconnecting, isMinimized, printerId, attemptReconnect]); // Fullscreen change listener useEffect(() => { const handleFullscreenChange = () => { const nowFullscreen = !!document.fullscreenElement; setIsFullscreen(nowFullscreen); // Reset zoom and pan when exiting fullscreen if (!nowFullscreen) { setZoomLevel(1); setPanOffset({ x: 0, y: 0 }); } }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); 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 || !imgRef.current) { return { x: 200, y: 150 }; } 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 handleStreamError = () => { setStreamLoading(false); if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { attemptReconnect(); } else { setStreamError(true); } }; const handleStreamLoad = () => { setStreamLoading(false); setStreamError(false); setReconnectAttempts(0); setIsReconnecting(false); if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); }; const refresh = () => { setStreamLoading(true); setStreamError(false); setReconnectAttempts(0); setIsReconnecting(false); if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); const stopHeaders: Record = {}; const stopToken = getAuthToken(); if (stopToken) stopHeaders['Authorization'] = `Bearer ${stopToken}`; fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', headers: stopHeaders }).catch(() => {}); if (imgRef.current) imgRef.current.src = ''; setTimeout(() => setImageKey(Date.now()), 100); }; // Drag handlers const handleMouseDown = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('.no-drag')) return; setIsDragging(true); setDragOffset({ x: e.clientX - state.x, y: e.clientY - state.y, }); }; const handleDragTouchStart = (e: React.TouchEvent) => { if ((e.target as HTMLElement).closest('.no-drag')) return; const touch = e.touches[0]; setIsDragging(true); setDragOffset({ x: touch.clientX - state.x, y: touch.clientY - state.y, }); }; // Resize handlers const handleResizeMouseDown = (e: React.MouseEvent) => { e.stopPropagation(); setIsResizing(true); }; const handleResizeTouchStart = (e: React.TouchEvent) => { e.stopPropagation(); setIsResizing(true); }; useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (isDragging) { setState((prev) => ({ ...prev, x: Math.max(0, Math.min(e.clientX - dragOffset.x, window.innerWidth - prev.width)), y: Math.max(0, Math.min(e.clientY - dragOffset.y, window.innerHeight - prev.height)), })); } else if (isResizing && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setState((prev) => ({ ...prev, width: Math.max(200, Math.min(e.clientX - rect.left, window.innerWidth - prev.x - 10)), height: Math.max(150, Math.min(e.clientY - rect.top, window.innerHeight - prev.y - 10)), })); } }; const handleTouchMove = (e: TouchEvent) => { if (!isDragging && !isResizing) return; e.preventDefault(); const touch = e.touches[0]; if (isDragging) { setState((prev) => ({ ...prev, x: Math.max(0, Math.min(touch.clientX - dragOffset.x, window.innerWidth - prev.width)), y: Math.max(0, Math.min(touch.clientY - dragOffset.y, window.innerHeight - prev.height)), })); } else if (isResizing && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setState((prev) => ({ ...prev, width: Math.max(200, Math.min(touch.clientX - rect.left, window.innerWidth - prev.x - 10)), height: Math.max(150, Math.min(touch.clientY - rect.top, window.innerHeight - prev.y - 10)), })); } }; const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); }; if (isDragging || isResizing) { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchend', handleMouseUp); document.addEventListener('touchcancel', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleMouseUp); document.removeEventListener('touchcancel', handleMouseUp); }; } }, [isDragging, isResizing, dragOffset]); const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`; return (
{/* Header */}
{printer?.name || printerName}
{/* Video area */} {!isMinimized && (
{streamLoading && !isReconnecting && (
)} {isReconnecting && (

Reconnecting in {reconnectCountdown}s...

)} {streamError && !isReconnecting && (

Camera unavailable

)} Camera stream 1 ? (isPanning ? 'grabbing' : 'grab') : 'default', }} onError={handleStreamError} onLoad={handleStreamLoad} onMouseDown={handleImageMouseDown} draggable={false} /> {/* Zoom controls */}
{/* Resize handle - hide in fullscreen */} {!isFullscreen && (
)}
)} {/* Skip Objects Modal */} setShowSkipObjectsModal(false)} />
); }