diff --git a/frontend/src/components/EmbeddedCameraViewer.tsx b/frontend/src/components/EmbeddedCameraViewer.tsx
index f4eda5cc1..d381133c7 100644
--- a/frontend/src/components/EmbeddedCameraViewer.tsx
+++ b/frontend/src/components/EmbeddedCameraViewer.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
-import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff } from 'lucide-react';
+import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
import { api } from '../api/client';
interface EmbeddedCameraViewerProps {
@@ -65,6 +65,11 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
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 });
// Stream state
const [streamError, setStreamError] = useState(false);
@@ -202,6 +207,81 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
};
}, [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 });
+ }
+ };
+
+ const handleImageMouseMove = (e: React.MouseEvent) => {
+ if (isPanning && zoomLevel > 1) {
+ const newX = e.clientX - panStart.x;
+ const newY = e.clientY - panStart.y;
+ // Limit panning based on zoom level
+ const maxPan = (zoomLevel - 1) * 150;
+ setPanOffset({
+ x: Math.max(-maxPan, Math.min(maxPan, newX)),
+ y: Math.max(-maxPan, Math.min(maxPan, newY)),
+ });
+ }
+ };
+
+ const handleImageMouseUp = () => {
+ setIsPanning(false);
+ };
+
+ const resetZoom = () => {
+ setZoomLevel(1);
+ setPanOffset({ x: 0, y: 0 });
+ };
+
const handleStreamError = () => {
setStreamLoading(false);
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
@@ -288,8 +368,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
return (
+