From 3c9a03cadc5cdaeb9821f7e9ecee25867e4e4820 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 20 Jan 2026 12:57:22 +0100 Subject: [PATCH] Add camera view mode setting for window or embedded display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 + backend/app/api/routes/settings.py | 4 + backend/app/schemas/settings.py | 7 + .../tests/integration/test_settings_api.py | 43 + frontend/src/api/client.ts | 2 + .../src/components/EmbeddedCameraViewer.tsx | 389 + frontend/src/pages/PrintersPage.tsx | 48 +- frontend/src/pages/SettingsPage.tsx | 38 +- static/assets/index-BQHdfJWP.js | 8663 ----------------- static/assets/index-DuPkbmqh.js | 8663 +++++++++++++++++ ...{index-Sa64ulbW.css => index-t4RhRNeD.css} | 2 +- static/index.html | 4 +- 12 files changed, 9187 insertions(+), 8681 deletions(-) create mode 100644 frontend/src/components/EmbeddedCameraViewer.tsx delete mode 100644 static/assets/index-BQHdfJWP.js create mode 100644 static/assets/index-DuPkbmqh.js rename static/assets/{index-Sa64ulbW.css => index-t4RhRNeD.css} (67%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f216931..467fcf2e4 100644 --- a/CHANGELOG.md +++ b/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: diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 759e0b001..7db46c2f9 100644 --- a/backend/app/api/routes/settings.py +++ b/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: diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index da8f5da4f..9c2487923 100644 --- a/backend/app/schemas/settings.py +++ b/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 diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py index 9a9c09fbd..7ab4757b3 100644 --- a/backend/tests/integration/test_settings_api.py +++ b/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"] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1e4fb20e2..2539442d7 100644 --- a/frontend/src/api/client.ts +++ b/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; diff --git a/frontend/src/components/EmbeddedCameraViewer.tsx b/frontend/src/components/EmbeddedCameraViewer.tsx new file mode 100644 index 000000000..100fe6b12 --- /dev/null +++ b/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(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(null); + const imgRef = useRef(null); + const reconnectTimerRef = useRef(null); + const countdownIntervalRef = useRef(null); + const stallCheckIntervalRef = useRef(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 ( +
+ {/* Header */} +
+
+ + {printer?.name || printerName} +
+
+ + + +
+
+ + {/* Video area */} + {!isMinimized && ( +
+ {streamLoading && !isReconnecting && ( +
+ +
+ )} + {isReconnecting && ( +
+
+ +

+ Reconnecting in {reconnectCountdown}s... +

+
+
+ )} + {streamError && !isReconnecting && ( +
+
+ +

Camera unavailable

+ +
+
+ )} + Camera stream + + {/* Resize handle */} +
+ + + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 1bbb59f90..3382b155d 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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'} >