Ver código fonte

Add camera view mode setting for window or embedded display

Users can now choose how camera streams open via Settings → General → Camera:
- "New Window" (default): Opens camera in a separate browser window
- "Embedded": Shows camera as a draggable, resizable overlay on the main screen

The embedded camera viewer features:
- Draggable header to reposition anywhere on screen
- Resizable via bottom-right corner handle
- Position and size persist to localStorage across sessions
- Minimize/maximize toggle
- Auto-reconnect with exponential backoff on stream errors
- Stall detection for frozen streams

Closes #105
maziggy 7 meses atrás
pai
commit
3c9a03cadc

+ 5 - 0
CHANGELOG.md

@@ -14,6 +14,11 @@ All notable changes to Bambuddy will be documented in this file.
   - Sort by size (smallest or largest first)
   - Sort by date (oldest or newest first)
   - Directories always sorted first
+- **Camera View Mode Setting** - Choose how camera streams open:
+  - "New Window" (default): Opens camera in a separate browser window
+  - "Embedded": Shows camera as a floating overlay on the main screen
+  - Embedded viewer is draggable and resizable with persistent position/size
+  - Configure in Settings → General → Camera section
 
 ### Fixed
 - **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:

+ 4 - 0
backend/app/api/routes/settings.py

@@ -137,6 +137,8 @@ async def update_settings(
         await set_setting(db, key, str_value)
 
     await db.commit()
+    # Expire all objects to ensure fresh reads after commit
+    db.expire_all()
 
     # Reconfigure MQTT relay if any MQTT settings changed
     if mqtt_updated:
@@ -214,6 +216,7 @@ async def update_spoolman_settings(
         await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
 
     await db.commit()
+    db.expire_all()
 
     # Return updated settings
     return await get_spoolman_settings(db)
@@ -1990,6 +1993,7 @@ async def update_virtual_printer_settings(
     if model is not None:
         await set_setting(db, "virtual_printer_model", model)
     await db.commit()
+    db.expire_all()
 
     # Reconfigure virtual printer
     try:

+ 7 - 0
backend/app/schemas/settings.py

@@ -100,6 +100,12 @@ class AppSettings(BaseModel):
         description="Show warning when free disk space falls below this threshold (GB)",
     )
 
+    # Camera view settings
+    camera_view_mode: str = Field(
+        default="window",
+        description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -149,3 +155,4 @@ class AppSettingsUpdate(BaseModel):
     ha_token: str | None = None
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
+    camera_view_mode: str | None = None

+ 43 - 0
backend/tests/integration/test_settings_api.py

@@ -285,3 +285,46 @@ class TestSettingsAPI:
         assert result["mqtt_port"] == 1883
         assert result["mqtt_topic_prefix"] == "bambuddy"
         assert result["mqtt_use_tls"] is False
+
+    # ========================================================================
+    # Camera settings tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_camera_view_mode(self, async_client: AsyncClient):
+        """Verify camera view mode can be updated."""
+        response = await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
+
+        assert response.status_code == 200
+        assert response.json()["camera_view_mode"] == "embedded"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_view_mode_persists(self, async_client: AsyncClient):
+        """CRITICAL: Verify camera view mode persists after update."""
+        # Update to embedded
+        await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
+
+        # Verify persistence in new request
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["camera_view_mode"] == "embedded"
+
+        # Update back to window
+        await async_client.put("/api/v1/settings/", json={"camera_view_mode": "window"})
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["camera_view_mode"] == "window"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_view_mode_default(self, async_client: AsyncClient):
+        """Verify camera view mode has correct default value."""
+        # Reset by requesting settings (default should be 'window')
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert "camera_view_mode" in result
+        # Default is 'window' as defined in schema
+        assert result["camera_view_mode"] in ["window", "embedded"]

+ 2 - 0
frontend/src/api/client.ts

@@ -593,6 +593,8 @@ export interface AppSettings {
   // File Manager / Library settings
   library_archive_mode: 'always' | 'never' | 'ask';
   library_disk_warning_gb: number;
+  // Camera view settings
+  camera_view_mode: 'window' | 'embedded';
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 389 - 0
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -0,0 +1,389 @@
+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 { api } from '../api/client';
+
+interface EmbeddedCameraViewerProps {
+  printerId: number;
+  printerName: string;
+  onClose: () => void;
+}
+
+const STORAGE_KEY = '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, onClose }: EmbeddedCameraViewerProps) {
+  // Load saved state or use defaults
+  const loadState = (): CameraState => {
+    try {
+      const saved = localStorage.getItem(STORAGE_KEY);
+      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
+    }
+    return DEFAULT_STATE;
+  };
+
+  const [state, setState] = useState<CameraState>(loadState);
+  const [isDragging, setIsDragging] = useState(false);
+  const [isResizing, setIsResizing] = useState(false);
+  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
+  const [isMinimized, setIsMinimized] = useState(false);
+
+  // 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<HTMLDivElement>(null);
+  const imgRef = useRef<HTMLImageElement>(null);
+  const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
+  const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
+
+  // Fetch printer info
+  const { data: printer } = useQuery({
+    queryKey: ['printer', printerId],
+    queryFn: () => api.getPrinter(printerId),
+    enabled: printerId > 0,
+  });
+
+  // Save state to localStorage
+  useEffect(() => {
+    const saveTimeout = setTimeout(() => {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+    }, 500);
+    return () => clearTimeout(saveTimeout);
+  }, [state]);
+
+  // 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;
+        navigator.sendBeacon(stopUrl);
+      }
+    };
+
+    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 response = await fetch(`/api/v1/printers/${printerId}/camera/status`);
+        if (response.ok) {
+          const status = await response.json();
+          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]);
+
+  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);
+
+    fetch(`/api/v1/printers/${printerId}/camera/stop`).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,
+    });
+  };
+
+  // Resize handlers
+  const handleResizeMouseDown = (e: React.MouseEvent) => {
+    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 handleMouseUp = () => {
+      setIsDragging(false);
+      setIsResizing(false);
+    };
+
+    if (isDragging || isResizing) {
+      document.addEventListener('mousemove', handleMouseMove);
+      document.addEventListener('mouseup', handleMouseUp);
+      return () => {
+        document.removeEventListener('mousemove', handleMouseMove);
+        document.removeEventListener('mouseup', handleMouseUp);
+      };
+    }
+  }, [isDragging, isResizing, dragOffset]);
+
+  const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=10&t=${imageKey}`;
+
+  return (
+    <div
+      ref={containerRef}
+      className="fixed z-50 bg-bambu-dark-secondary rounded-lg shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
+      style={{
+        left: state.x,
+        top: state.y,
+        width: isMinimized ? 200 : state.width,
+        height: isMinimized ? 40 : state.height,
+        cursor: isDragging ? 'grabbing' : 'default',
+      }}
+    >
+      {/* Header */}
+      <div
+        className="flex items-center justify-between px-3 py-2 bg-bambu-dark border-b border-bambu-dark-tertiary cursor-grab active:cursor-grabbing"
+        onMouseDown={handleMouseDown}
+      >
+        <div className="flex items-center gap-2 text-sm text-white truncate">
+          <GripVertical className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+          <span className="truncate">{printer?.name || printerName}</span>
+        </div>
+        <div className="flex items-center gap-1 no-drag">
+          <button
+            onClick={refresh}
+            disabled={streamLoading || isReconnecting}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+            title="Refresh stream"
+          >
+            <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
+          </button>
+          <button
+            onClick={() => setIsMinimized(!isMinimized)}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded"
+            title={isMinimized ? 'Expand' : 'Minimize'}
+          >
+            {isMinimized ? (
+              <Maximize2 className="w-3.5 h-3.5 text-bambu-gray" />
+            ) : (
+              <Minimize2 className="w-3.5 h-3.5 text-bambu-gray" />
+            )}
+          </button>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-red-500/20 rounded"
+            title="Close"
+          >
+            <X className="w-3.5 h-3.5 text-bambu-gray hover:text-red-400" />
+          </button>
+        </div>
+      </div>
+
+      {/* Video area */}
+      {!isMinimized && (
+        <div className="relative w-full h-[calc(100%-40px)] bg-black flex items-center justify-center">
+          {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" />
+            </div>
+          )}
+          {isReconnecting && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
+              <div className="text-center p-2">
+                <WifiOff className="w-6 h-6 text-orange-400 mx-auto mb-2" />
+                <p className="text-xs text-bambu-gray">
+                  Reconnecting in {reconnectCountdown}s...
+                </p>
+              </div>
+            </div>
+          )}
+          {streamError && !isReconnecting && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
+              <div className="text-center p-2">
+                <AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
+                <p className="text-xs text-bambu-gray mb-2">Camera unavailable</p>
+                <button
+                  onClick={refresh}
+                  className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
+                >
+                  Retry
+                </button>
+              </div>
+            </div>
+          )}
+          <img
+            ref={imgRef}
+            key={imageKey}
+            src={streamUrl}
+            alt="Camera stream"
+            className="max-w-full max-h-full object-contain"
+            onError={handleStreamError}
+            onLoad={handleStreamLoad}
+          />
+
+          {/* 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"
+            >
+              <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
+            </svg>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 36 - 12
frontend/src/pages/PrintersPage.tsx

@@ -58,6 +58,7 @@ import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { FileManagerModal } from '../components/FileManagerModal';
+import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
@@ -909,6 +910,8 @@ function PrinterCard({
   spoolmanEnabled = false,
   hasUnlinkedSpools = false,
   timeFormat = 'system',
+  cameraViewMode = 'window',
+  onOpenEmbeddedCamera,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -924,6 +927,8 @@ function PrinterCard({
   spoolmanEnabled?: boolean;
   hasUnlinkedSpools?: boolean;
   timeFormat?: 'system' | '12h' | '24h';
+  cameraViewMode?: 'window' | 'embedded';
+  onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
 }) {
   const queryClient = useQueryClient();
   const navigate = useNavigate();
@@ -2534,20 +2539,24 @@ function PrinterCard({
                 variant="secondary"
                 size="sm"
                 onClick={() => {
-                  // Use saved window state or defaults
-                  const saved = localStorage.getItem('cameraWindowState');
-                  const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
-                  const features = [
-                    `width=${state.width}`,
-                    `height=${state.height}`,
-                    state.left !== undefined ? `left=${state.left}` : '',
-                    state.top !== undefined ? `top=${state.top}` : '',
-                    'menubar=no,toolbar=no,location=no,status=no,noopener',
-                  ].filter(Boolean).join(',');
-                  window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
+                  if (cameraViewMode === 'embedded' && onOpenEmbeddedCamera) {
+                    onOpenEmbeddedCamera(printer.id, printer.name);
+                  } else {
+                    // Use saved window state or defaults
+                    const saved = localStorage.getItem('cameraWindowState');
+                    const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
+                    const features = [
+                      `width=${state.width}`,
+                      `height=${state.height}`,
+                      state.left !== undefined ? `left=${state.left}` : '',
+                      state.top !== undefined ? `top=${state.top}` : '',
+                      'menubar=no,toolbar=no,location=no,status=no,noopener',
+                    ].filter(Boolean).join(',');
+                    window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
+                  }
                 }}
                 disabled={!status?.connected}
-                title="Open camera in new window"
+                title={cameraViewMode === 'embedded' ? 'Open camera overlay' : 'Open camera in new window'}
               >
                 <Video className="w-4 h-4" />
               </Button>
@@ -3793,6 +3802,8 @@ export function PrintersPage() {
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const queryClient = useQueryClient();
+  // Embedded camera viewer state
+  const [embeddedCameraPrinter, setEmbeddedCameraPrinter] = useState<{ id: number; name: string } | null>(null);
 
   const { data: printers, isLoading } = useQuery({
     queryKey: ['printers'],
@@ -4133,6 +4144,8 @@ export function PrintersPage() {
                     spoolmanEnabled={spoolmanEnabled}
                     hasUnlinkedSpools={hasUnlinkedSpools}
                     timeFormat={settings?.time_format || 'system'}
+                    cameraViewMode={settings?.camera_view_mode || 'window'}
+                    onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinter({ id, name })}
                   />
                 ))}
               </div>
@@ -4159,6 +4172,8 @@ export function PrintersPage() {
                 tempFair: Number(settings.ams_temp_fair) || 35,
               } : undefined}
               timeFormat={settings?.time_format || 'system'}
+              cameraViewMode={settings?.camera_view_mode || 'window'}
+              onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinter({ id, name })}
             />
           ))}
         </div>
@@ -4171,6 +4186,15 @@ export function PrintersPage() {
           existingSerials={printers?.map(p => p.serial_number) || []}
         />
       )}
+
+      {/* Embedded Camera Viewer */}
+      {embeddedCameraPrinter && (
+        <EmbeddedCameraViewer
+          printerId={embeddedCameraPrinter.id}
+          printerName={embeddedCameraPrinter.name}
+          onClose={() => setEmbeddedCameraPrinter(null)}
+        />
+      )}
     </div>
   );
 }

+ 35 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { formatDateOnly } from '../utils/date';
@@ -379,7 +379,8 @@ export function SettingsPage() {
       settings.ha_url !== localSettings.ha_url ||
       settings.ha_token !== localSettings.ha_token ||
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
-      Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5);
+      Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
+      (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window');
 
     if (!hasChanges) {
       return;
@@ -438,6 +439,7 @@ export function SettingsPage() {
         ha_token: localSettings.ha_token,
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
+        camera_view_mode: localSettings.camera_view_mode,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -874,8 +876,38 @@ export function SettingsPage() {
 
         </div>
 
-        {/* Second Column - Cost, AMS & Spoolman */}
+        {/* Second Column - Camera, Cost, AMS & Spoolman */}
         <div className="space-y-6 flex-1 lg:max-w-md">
+          {/* Camera Settings */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Video className="w-5 h-5 text-bambu-green" />
+                Camera
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Camera View Mode
+                </label>
+                <select
+                  value={localSettings.camera_view_mode ?? 'window'}
+                  onChange={(e) => updateSetting('camera_view_mode', e.target.value as 'window' | 'embedded')}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="window">New Window</option>
+                  <option value="embedded">Embedded Overlay</option>
+                </select>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {localSettings.camera_view_mode === 'embedded'
+                    ? 'Camera opens in a resizable overlay on the main screen'
+                    : 'Camera opens in a separate browser window'}
+                </p>
+              </div>
+            </CardContent>
+          </Card>
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-BQHdfJWP.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-DuPkbmqh.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-t4RhRNeD.css


+ 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-BQHdfJWP.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Sa64ulbW.css">
+    <script type="module" crossorigin src="/assets/index-DuPkbmqh.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-t4RhRNeD.css">
   </head>
   <body>
     <div id="root"></div>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff