Browse Source

The camera viewer now has proper zoom and fullscreen support for both viewing modes

Closes #132
maziggy 4 months ago
parent
commit
f9915237a2

+ 149 - 17
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 (
     <div
       ref={containerRef}
-      className="fixed z-50 bg-bambu-dark-secondary rounded-lg shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
-      style={{
+      className={`${isFullscreen ? 'fixed inset-0 z-[100]' : 'fixed z-50 rounded-lg shadow-2xl border border-bambu-dark-tertiary'} bg-bambu-dark-secondary overflow-hidden`}
+      style={isFullscreen ? undefined : {
         left: state.x,
         top: state.y,
         width: isMinimized ? 200 : state.width,
@@ -315,6 +395,17 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           >
             <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
           </button>
+          <button
+            onClick={toggleFullscreen}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded"
+            title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
+          >
+            {isFullscreen ? (
+              <Minimize className="w-3.5 h-3.5 text-bambu-gray" />
+            ) : (
+              <Fullscreen className="w-3.5 h-3.5 text-bambu-gray" />
+            )}
+          </button>
           <button
             onClick={() => setIsMinimized(!isMinimized)}
             className="p-1 hover:bg-bambu-dark-tertiary rounded"
@@ -338,7 +429,13 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
 
       {/* Video area */}
       {!isMinimized && (
-        <div className="relative w-full h-[calc(100%-40px)] bg-black flex items-center justify-center">
+        <div
+          className={`relative w-full bg-black flex items-center justify-center overflow-hidden ${isFullscreen ? 'h-[calc(100%-40px)]' : 'h-[calc(100%-40px)]'}`}
+          onWheel={handleWheel}
+          onMouseMove={handleImageMouseMove}
+          onMouseUp={handleImageMouseUp}
+          onMouseLeave={handleImageMouseUp}
+        >
           {streamLoading && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
               <RefreshCw className="w-6 h-6 text-bambu-gray animate-spin" />
@@ -373,25 +470,60 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             key={imageKey}
             src={streamUrl}
             alt="Camera stream"
-            className="max-w-full max-h-full object-contain"
+            className="max-w-full max-h-full object-contain select-none"
+            style={{
+              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
+              cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
+            }}
             onError={handleStreamError}
             onLoad={handleStreamLoad}
+            onMouseDown={handleImageMouseDown}
+            draggable={false}
           />
 
-          {/* Resize handle */}
-          <div
-            className="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors"
-            onMouseDown={handleResizeMouseDown}
-            title="Drag to resize"
-          >
-            <svg
-              className="w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray"
-              viewBox="0 0 24 24"
-              fill="currentColor"
+          {/* Zoom controls */}
+          <div className="absolute bottom-2 left-2 flex items-center gap-1 bg-black/60 rounded px-1.5 py-1 no-drag">
+            <button
+              onClick={handleZoomOut}
+              disabled={zoomLevel <= 1}
+              className="p-1 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom out"
+            >
+              <ZoomOut className="w-3.5 h-3.5 text-white" />
+            </button>
+            <button
+              onClick={resetZoom}
+              className="px-1.5 py-0.5 text-xs text-white hover:bg-white/10 rounded min-w-[32px]"
+              title="Reset zoom"
             >
-              <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
-            </svg>
+              {Math.round(zoomLevel * 100)}%
+            </button>
+            <button
+              onClick={handleZoomIn}
+              disabled={zoomLevel >= 4}
+              className="p-1 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom in"
+            >
+              <ZoomIn className="w-3.5 h-3.5 text-white" />
+            </button>
           </div>
+
+          {/* Resize handle - hide in fullscreen */}
+          {!isFullscreen && (
+            <div
+              className="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors"
+              onMouseDown={handleResizeMouseDown}
+              title="Drag to resize"
+            >
+              <svg
+                className="w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray"
+                viewBox="0 0 24 24"
+                fill="currentColor"
+              >
+                <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
+              </svg>
+            </div>
+          )}
         </div>
       )}
     </div>

+ 103 - 3
frontend/src/pages/CameraPage.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
-import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff } from 'lucide-react';
+import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
 import { api } from '../api/client';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
@@ -22,6 +22,10 @@ export function CameraPage() {
   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 imgRef = useRef<HTMLImageElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -98,6 +102,9 @@ export function CameraPage() {
     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) {
@@ -307,6 +314,9 @@ export function CameraPage() {
     // 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);
     }
@@ -369,6 +379,57 @@ export function CameraPage() {
     }
   };
 
+  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) * 200;
+      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 currentUrl = transitioning
     ? ''
     : streamMode === 'stream'
@@ -442,7 +503,13 @@ export function CameraPage() {
       </div>
 
       {/* Video area */}
-      <div className="flex-1 flex items-center justify-center p-2">
+      <div
+        className="flex-1 flex items-center justify-center p-2 overflow-hidden"
+        onWheel={handleWheel}
+        onMouseMove={handleImageMouseMove}
+        onMouseUp={handleImageMouseUp}
+        onMouseLeave={handleImageMouseUp}
+      >
         <div className="relative w-full h-full flex items-center justify-center">
           {(streamLoading || transitioning) && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
@@ -493,10 +560,43 @@ export function CameraPage() {
             key={imageKey}
             src={currentUrl}
             alt="Camera stream"
-            className="max-w-full max-h-full object-contain"
+            className="max-w-full max-h-full object-contain select-none"
+            style={{
+              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
+              cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
+            }}
             onError={currentUrl ? handleStreamError : undefined}
             onLoad={currentUrl ? handleStreamLoad : undefined}
+            onMouseDown={handleImageMouseDown}
+            draggable={false}
           />
+
+          {/* Zoom controls */}
+          <div className="absolute bottom-4 left-4 flex items-center gap-1.5 bg-black/60 rounded-lg px-2 py-1.5">
+            <button
+              onClick={handleZoomOut}
+              disabled={zoomLevel <= 1}
+              className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom out"
+            >
+              <ZoomOut className="w-4 h-4 text-white" />
+            </button>
+            <button
+              onClick={resetZoom}
+              className="px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]"
+              title="Reset zoom"
+            >
+              {Math.round(zoomLevel * 100)}%
+            </button>
+            <button
+              onClick={handleZoomIn}
+              disabled={zoomLevel >= 4}
+              className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom in"
+            >
+              <ZoomIn className="w-4 h-4 text-white" />
+            </button>
+          </div>
         </div>
       </div>
     </div>

+ 6 - 1
frontend/src/pages/PrintersPage.tsx

@@ -3110,10 +3110,12 @@ function AddPrinterModal({
   };
 
   const selectPrinter = (printer: DiscoveredPrinter) => {
+    // Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial
+    const serialNumber = printer.serial.startsWith('unknown-') ? '' : printer.serial;
     setForm({
       ...form,
       name: printer.name || '',
-      serial_number: printer.serial,
+      serial_number: serialNumber,
       ip_address: printer.ip_address,
       model: mapModelCode(printer.model),
     });
@@ -3209,6 +3211,9 @@ function AddPrinterModal({
                       </p>
                       <p className="text-xs text-bambu-gray truncate">
                         {mapModelCode(printer.model) || 'Unknown'} • {printer.ip_address}
+                        {printer.serial.startsWith('unknown-') && (
+                          <span className="text-yellow-500"> • Serial required</span>
+                        )}
                       </p>
                     </div>
                     <ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-COZJGA_d.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C_p2QVEb.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DbTLv2ta.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-7nHE42SH.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-COZJGA_d.css">
+    <script type="module" crossorigin src="/assets/index-DbTLv2ta.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C_p2QVEb.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff