mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
The camera viewer now has proper zoom and fullscreen support for both viewing modes
Closes #132
This commit is contained in:
@@ -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"
|
||||
>
|
||||
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
|
||||
</svg>
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user