EmbeddedCameraViewer.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  1. import { useState, useEffect, useRef, useCallback } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize, Stethoscope } from 'lucide-react';
  5. import { api, getAuthToken, withStreamToken } from '../api/client';
  6. import { useToast } from '../contexts/ToastContext';
  7. import { useAuth } from '../contexts/AuthContext';
  8. import { ChamberLight } from './icons/ChamberLight';
  9. import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
  10. import { CameraDiagnoseModal } from './CameraDiagnoseModal';
  11. interface EmbeddedCameraViewerProps {
  12. printerId: number;
  13. printerName: string;
  14. viewerIndex?: number; // Used to offset multiple viewers
  15. onClose: () => void;
  16. }
  17. const STORAGE_KEY_PREFIX = 'embeddedCameraState_';
  18. const MAX_RECONNECT_ATTEMPTS = 5;
  19. const INITIAL_RECONNECT_DELAY = 2000;
  20. const MAX_RECONNECT_DELAY = 30000;
  21. const STALL_CHECK_INTERVAL = 5000;
  22. interface CameraState {
  23. x: number;
  24. y: number;
  25. width: number;
  26. height: number;
  27. }
  28. const DEFAULT_STATE: CameraState = {
  29. x: window.innerWidth - 420,
  30. y: 20,
  31. width: 400,
  32. height: 300,
  33. };
  34. export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
  35. const { t } = useTranslation();
  36. const queryClient = useQueryClient();
  37. const { showToast } = useToast();
  38. const { hasPermission } = useAuth();
  39. // Printer-specific storage key
  40. const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
  41. // Load saved state or use defaults (offset for new viewers without saved state)
  42. const loadState = (): CameraState => {
  43. try {
  44. const saved = localStorage.getItem(storageKey);
  45. if (saved) {
  46. const state = JSON.parse(saved);
  47. // Validate state is on screen
  48. return {
  49. x: Math.min(Math.max(0, state.x), window.innerWidth - 100),
  50. y: Math.min(Math.max(0, state.y), window.innerHeight - 100),
  51. width: Math.max(200, Math.min(state.width, window.innerWidth - 20)),
  52. height: Math.max(150, Math.min(state.height, window.innerHeight - 20)),
  53. };
  54. }
  55. } catch {
  56. // Ignore parse errors
  57. }
  58. // Offset new viewers so they don't stack exactly on top of each other
  59. const offset = viewerIndex * 30;
  60. return {
  61. ...DEFAULT_STATE,
  62. x: Math.max(0, DEFAULT_STATE.x - offset),
  63. y: Math.max(0, DEFAULT_STATE.y + offset),
  64. };
  65. };
  66. const [state, setState] = useState<CameraState>(loadState);
  67. const [isDragging, setIsDragging] = useState(false);
  68. const [isResizing, setIsResizing] = useState(false);
  69. const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
  70. const [isMinimized, setIsMinimized] = useState(false);
  71. const [isFullscreen, setIsFullscreen] = useState(false);
  72. const [zoomLevel, setZoomLevel] = useState(1);
  73. const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
  74. const [isPanning, setIsPanning] = useState(false);
  75. const [panStart, setPanStart] = useState({ x: 0, y: 0 });
  76. const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
  77. const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
  78. // Stream state
  79. const [streamError, setStreamError] = useState(false);
  80. const [streamLoading, setStreamLoading] = useState(true);
  81. const [imageKey, setImageKey] = useState(Date.now());
  82. const [reconnectAttempts, setReconnectAttempts] = useState(0);
  83. const [isReconnecting, setIsReconnecting] = useState(false);
  84. const [reconnectCountdown, setReconnectCountdown] = useState(0);
  85. const containerRef = useRef<HTMLDivElement>(null);
  86. const imgRef = useRef<HTMLImageElement>(null);
  87. const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
  88. const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
  89. const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
  90. const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
  91. // Modal opens from the error-state "Diagnose" button when the user
  92. // hits "Camera unavailable" — saves a round trip through "open a
  93. // ticket → wait for response → check setting". See #1395 follow-up.
  94. const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
  95. // Fetch printer info
  96. const { data: printer } = useQuery({
  97. queryKey: ['printer', printerId],
  98. queryFn: () => api.getPrinter(printerId),
  99. enabled: printerId > 0,
  100. });
  101. // Fetch printer status for light toggle and skip objects
  102. const { data: status } = useQuery({
  103. queryKey: ['printerStatus', printerId],
  104. queryFn: () => api.getPrinterStatus(printerId),
  105. refetchInterval: 30000,
  106. enabled: printerId > 0,
  107. });
  108. // Chamber light mutation with optimistic update
  109. const chamberLightMutation = useMutation({
  110. mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
  111. onMutate: async (on) => {
  112. await queryClient.cancelQueries({ queryKey: ['printerStatus', printerId] });
  113. const previousStatus = queryClient.getQueryData(['printerStatus', printerId]);
  114. queryClient.setQueryData(['printerStatus', printerId], (old: typeof status) => ({
  115. ...old,
  116. chamber_light: on,
  117. }));
  118. return { previousStatus };
  119. },
  120. onSuccess: (_, on) => {
  121. showToast(`Chamber light ${on ? 'on' : 'off'}`);
  122. },
  123. onError: (error: Error, _, context) => {
  124. if (context?.previousStatus) {
  125. queryClient.setQueryData(['printerStatus', printerId], context.previousStatus);
  126. }
  127. showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');
  128. },
  129. });
  130. const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2;
  131. // Save state to localStorage (printer-specific)
  132. useEffect(() => {
  133. const saveTimeout = setTimeout(() => {
  134. localStorage.setItem(storageKey, JSON.stringify(state));
  135. }, 500);
  136. return () => clearTimeout(saveTimeout);
  137. }, [state, storageKey]);
  138. // Cleanup on unmount
  139. const stopSentRef = useRef(false);
  140. useEffect(() => {
  141. stopSentRef.current = false;
  142. const stopUrl = `/api/v1/printers/${printerId}/camera/stop`;
  143. const sendStopOnce = () => {
  144. if (printerId > 0 && !stopSentRef.current) {
  145. stopSentRef.current = true;
  146. const headers: Record<string, string> = {};
  147. const token = getAuthToken();
  148. if (token) headers['Authorization'] = `Bearer ${token}`;
  149. fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});
  150. }
  151. };
  152. const imgElement = imgRef.current;
  153. return () => {
  154. if (imgElement) {
  155. imgElement.src = '';
  156. }
  157. sendStopOnce();
  158. if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
  159. if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
  160. if (stallCheckIntervalRef.current) clearInterval(stallCheckIntervalRef.current);
  161. };
  162. }, [printerId]);
  163. // Auto-hide loading after timeout
  164. useEffect(() => {
  165. if (streamLoading) {
  166. const timer = setTimeout(() => setStreamLoading(false), 3000);
  167. return () => clearTimeout(timer);
  168. }
  169. }, [streamLoading, imageKey]);
  170. // Auto-reconnect logic
  171. const attemptReconnect = useCallback(() => {
  172. if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
  173. setIsReconnecting(false);
  174. setStreamError(true);
  175. return;
  176. }
  177. const delay = Math.min(
  178. INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),
  179. MAX_RECONNECT_DELAY
  180. );
  181. setIsReconnecting(true);
  182. setReconnectCountdown(Math.ceil(delay / 1000));
  183. countdownIntervalRef.current = setInterval(() => {
  184. setReconnectCountdown((prev) => {
  185. if (prev <= 1) {
  186. if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
  187. return 0;
  188. }
  189. return prev - 1;
  190. });
  191. }, 1000);
  192. reconnectTimerRef.current = setTimeout(() => {
  193. setReconnectAttempts((prev) => prev + 1);
  194. setIsReconnecting(false);
  195. setStreamLoading(true);
  196. setStreamError(false);
  197. if (imgRef.current) imgRef.current.src = '';
  198. setImageKey(Date.now());
  199. }, delay);
  200. }, [reconnectAttempts]);
  201. // Stall detection
  202. useEffect(() => {
  203. if (streamLoading || isReconnecting || isMinimized) {
  204. if (stallCheckIntervalRef.current) {
  205. clearInterval(stallCheckIntervalRef.current);
  206. stallCheckIntervalRef.current = null;
  207. }
  208. return;
  209. }
  210. stallCheckIntervalRef.current = setInterval(async () => {
  211. try {
  212. const status = await api.getCameraStatus(printerId);
  213. if (status.stalled || (!status.active && !streamError)) {
  214. if (stallCheckIntervalRef.current) {
  215. clearInterval(stallCheckIntervalRef.current);
  216. stallCheckIntervalRef.current = null;
  217. }
  218. setStreamLoading(false);
  219. attemptReconnect();
  220. }
  221. } catch {
  222. // Ignore errors
  223. }
  224. }, STALL_CHECK_INTERVAL);
  225. return () => {
  226. if (stallCheckIntervalRef.current) {
  227. clearInterval(stallCheckIntervalRef.current);
  228. stallCheckIntervalRef.current = null;
  229. }
  230. };
  231. }, [streamLoading, streamError, isReconnecting, isMinimized, printerId, attemptReconnect]);
  232. // Fullscreen change listener
  233. useEffect(() => {
  234. const handleFullscreenChange = () => {
  235. const nowFullscreen = !!document.fullscreenElement;
  236. setIsFullscreen(nowFullscreen);
  237. // Reset zoom and pan when exiting fullscreen
  238. if (!nowFullscreen) {
  239. setZoomLevel(1);
  240. setPanOffset({ x: 0, y: 0 });
  241. }
  242. };
  243. document.addEventListener('fullscreenchange', handleFullscreenChange);
  244. return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
  245. }, []);
  246. const toggleFullscreen = () => {
  247. if (!containerRef.current) return;
  248. if (document.fullscreenElement) {
  249. document.exitFullscreen();
  250. } else {
  251. containerRef.current.requestFullscreen();
  252. }
  253. };
  254. const handleZoomIn = () => {
  255. setZoomLevel(prev => Math.min(prev + 0.5, 4));
  256. };
  257. const handleZoomOut = () => {
  258. setZoomLevel(prev => {
  259. const newZoom = Math.max(prev - 0.5, 1);
  260. if (newZoom === 1) setPanOffset({ x: 0, y: 0 });
  261. return newZoom;
  262. });
  263. };
  264. const handleWheel = (e: React.WheelEvent) => {
  265. e.preventDefault();
  266. if (e.deltaY < 0) {
  267. handleZoomIn();
  268. } else {
  269. handleZoomOut();
  270. }
  271. };
  272. const handleImageMouseDown = (e: React.MouseEvent) => {
  273. if (zoomLevel > 1) {
  274. e.preventDefault();
  275. setIsPanning(true);
  276. setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
  277. }
  278. };
  279. // Calculate max pan based on container size and zoom level
  280. const getMaxPan = useCallback(() => {
  281. if (!containerRef.current || !imgRef.current) {
  282. return { x: 200, y: 150 };
  283. }
  284. const container = containerRef.current.getBoundingClientRect();
  285. // Allow panning up to half the zoomed overflow in each direction
  286. const maxX = (container.width * (zoomLevel - 1)) / 2;
  287. const maxY = (container.height * (zoomLevel - 1)) / 2;
  288. return { x: Math.max(50, maxX), y: Math.max(50, maxY) };
  289. }, [zoomLevel]);
  290. const handleImageMouseMove = (e: React.MouseEvent) => {
  291. if (isPanning && zoomLevel > 1) {
  292. const newX = e.clientX - panStart.x;
  293. const newY = e.clientY - panStart.y;
  294. const maxPan = getMaxPan();
  295. setPanOffset({
  296. x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
  297. y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
  298. });
  299. }
  300. };
  301. const handleImageMouseUp = () => {
  302. setIsPanning(false);
  303. };
  304. // Touch event handlers for mobile
  305. const getTouchDistance = (touches: React.TouchList) => {
  306. if (touches.length < 2) return 0;
  307. const dx = touches[0].clientX - touches[1].clientX;
  308. const dy = touches[0].clientY - touches[1].clientY;
  309. return Math.sqrt(dx * dx + dy * dy);
  310. };
  311. const getTouchCenter = (touches: React.TouchList) => {
  312. if (touches.length < 2) {
  313. return { x: touches[0].clientX, y: touches[0].clientY };
  314. }
  315. return {
  316. x: (touches[0].clientX + touches[1].clientX) / 2,
  317. y: (touches[0].clientY + touches[1].clientY) / 2,
  318. };
  319. };
  320. const handleTouchStart = (e: React.TouchEvent) => {
  321. if (e.touches.length === 2) {
  322. // Pinch gesture start
  323. e.preventDefault();
  324. setLastTouchDistance(getTouchDistance(e.touches));
  325. setLastTouchCenter(getTouchCenter(e.touches));
  326. } else if (e.touches.length === 1 && zoomLevel > 1) {
  327. // Single touch pan start
  328. e.preventDefault();
  329. setIsPanning(true);
  330. setPanStart({
  331. x: e.touches[0].clientX - panOffset.x,
  332. y: e.touches[0].clientY - panOffset.y,
  333. });
  334. }
  335. };
  336. const handleTouchMove = (e: React.TouchEvent) => {
  337. if (e.touches.length === 2 && lastTouchDistance !== null) {
  338. // Pinch gesture
  339. e.preventDefault();
  340. const newDistance = getTouchDistance(e.touches);
  341. const scale = newDistance / lastTouchDistance;
  342. setZoomLevel(prev => {
  343. const newZoom = Math.max(1, Math.min(4, prev * scale));
  344. if (newZoom === 1) {
  345. setPanOffset({ x: 0, y: 0 });
  346. }
  347. return newZoom;
  348. });
  349. setLastTouchDistance(newDistance);
  350. // Also handle pan during pinch
  351. const newCenter = getTouchCenter(e.touches);
  352. if (lastTouchCenter) {
  353. const maxPan = getMaxPan();
  354. setPanOffset(prev => ({
  355. x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),
  356. y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),
  357. }));
  358. }
  359. setLastTouchCenter(newCenter);
  360. } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {
  361. // Single touch pan
  362. e.preventDefault();
  363. const newX = e.touches[0].clientX - panStart.x;
  364. const newY = e.touches[0].clientY - panStart.y;
  365. const maxPan = getMaxPan();
  366. setPanOffset({
  367. x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
  368. y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
  369. });
  370. }
  371. };
  372. const handleTouchEnd = (e: React.TouchEvent) => {
  373. if (e.touches.length < 2) {
  374. setLastTouchDistance(null);
  375. setLastTouchCenter(null);
  376. }
  377. if (e.touches.length === 0) {
  378. setIsPanning(false);
  379. }
  380. };
  381. const resetZoom = () => {
  382. setZoomLevel(1);
  383. setPanOffset({ x: 0, y: 0 });
  384. };
  385. const handleStreamError = () => {
  386. setStreamLoading(false);
  387. if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
  388. attemptReconnect();
  389. } else {
  390. setStreamError(true);
  391. }
  392. };
  393. const handleStreamLoad = () => {
  394. setStreamLoading(false);
  395. setStreamError(false);
  396. setReconnectAttempts(0);
  397. setIsReconnecting(false);
  398. if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
  399. if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
  400. };
  401. const refresh = () => {
  402. setStreamLoading(true);
  403. setStreamError(false);
  404. setReconnectAttempts(0);
  405. setIsReconnecting(false);
  406. if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
  407. if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
  408. const stopHeaders: Record<string, string> = {};
  409. const stopToken = getAuthToken();
  410. if (stopToken) stopHeaders['Authorization'] = `Bearer ${stopToken}`;
  411. fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', headers: stopHeaders }).catch(() => {});
  412. if (imgRef.current) imgRef.current.src = '';
  413. setTimeout(() => setImageKey(Date.now()), 100);
  414. };
  415. // Drag handlers
  416. const handleMouseDown = (e: React.MouseEvent) => {
  417. if ((e.target as HTMLElement).closest('.no-drag')) return;
  418. setIsDragging(true);
  419. setDragOffset({
  420. x: e.clientX - state.x,
  421. y: e.clientY - state.y,
  422. });
  423. };
  424. const handleDragTouchStart = (e: React.TouchEvent) => {
  425. if ((e.target as HTMLElement).closest('.no-drag')) return;
  426. const touch = e.touches[0];
  427. setIsDragging(true);
  428. setDragOffset({
  429. x: touch.clientX - state.x,
  430. y: touch.clientY - state.y,
  431. });
  432. };
  433. // Resize handlers
  434. const handleResizeMouseDown = (e: React.MouseEvent) => {
  435. e.stopPropagation();
  436. setIsResizing(true);
  437. };
  438. const handleResizeTouchStart = (e: React.TouchEvent) => {
  439. e.stopPropagation();
  440. setIsResizing(true);
  441. };
  442. useEffect(() => {
  443. const handleMouseMove = (e: MouseEvent) => {
  444. if (isDragging) {
  445. setState((prev) => ({
  446. ...prev,
  447. x: Math.max(0, Math.min(e.clientX - dragOffset.x, window.innerWidth - prev.width)),
  448. y: Math.max(0, Math.min(e.clientY - dragOffset.y, window.innerHeight - prev.height)),
  449. }));
  450. } else if (isResizing && containerRef.current) {
  451. const rect = containerRef.current.getBoundingClientRect();
  452. setState((prev) => ({
  453. ...prev,
  454. width: Math.max(200, Math.min(e.clientX - rect.left, window.innerWidth - prev.x - 10)),
  455. height: Math.max(150, Math.min(e.clientY - rect.top, window.innerHeight - prev.y - 10)),
  456. }));
  457. }
  458. };
  459. const handleTouchMove = (e: TouchEvent) => {
  460. if (!isDragging && !isResizing) return;
  461. e.preventDefault();
  462. const touch = e.touches[0];
  463. if (isDragging) {
  464. setState((prev) => ({
  465. ...prev,
  466. x: Math.max(0, Math.min(touch.clientX - dragOffset.x, window.innerWidth - prev.width)),
  467. y: Math.max(0, Math.min(touch.clientY - dragOffset.y, window.innerHeight - prev.height)),
  468. }));
  469. } else if (isResizing && containerRef.current) {
  470. const rect = containerRef.current.getBoundingClientRect();
  471. setState((prev) => ({
  472. ...prev,
  473. width: Math.max(200, Math.min(touch.clientX - rect.left, window.innerWidth - prev.x - 10)),
  474. height: Math.max(150, Math.min(touch.clientY - rect.top, window.innerHeight - prev.y - 10)),
  475. }));
  476. }
  477. };
  478. const handleMouseUp = () => {
  479. setIsDragging(false);
  480. setIsResizing(false);
  481. };
  482. if (isDragging || isResizing) {
  483. document.addEventListener('mousemove', handleMouseMove);
  484. document.addEventListener('mouseup', handleMouseUp);
  485. document.addEventListener('touchmove', handleTouchMove, { passive: false });
  486. document.addEventListener('touchend', handleMouseUp);
  487. document.addEventListener('touchcancel', handleMouseUp);
  488. return () => {
  489. document.removeEventListener('mousemove', handleMouseMove);
  490. document.removeEventListener('mouseup', handleMouseUp);
  491. document.removeEventListener('touchmove', handleTouchMove);
  492. document.removeEventListener('touchend', handleMouseUp);
  493. document.removeEventListener('touchcancel', handleMouseUp);
  494. };
  495. }
  496. }, [isDragging, isResizing, dragOffset]);
  497. const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`);
  498. return (
  499. <div
  500. ref={containerRef}
  501. className={`${isFullscreen ? 'fixed inset-0 z-[100]' : 'fixed z-40 rounded-lg shadow-2xl border border-bambu-dark-tertiary'} bg-bambu-dark-secondary overflow-hidden`}
  502. style={isFullscreen ? undefined : {
  503. left: state.x,
  504. top: state.y,
  505. width: isMinimized ? 200 : state.width,
  506. height: isMinimized ? 40 : state.height,
  507. cursor: isDragging ? 'grabbing' : 'default',
  508. }}
  509. >
  510. {/* Header */}
  511. <div
  512. className="flex items-center justify-between px-3 py-2 bg-bambu-dark border-b border-bambu-dark-tertiary cursor-grab active:cursor-grabbing"
  513. onMouseDown={handleMouseDown}
  514. onTouchStart={handleDragTouchStart}
  515. >
  516. <div className="flex items-center gap-2 text-sm text-white truncate">
  517. <GripVertical className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  518. <span className="truncate">{printer?.name || printerName}</span>
  519. </div>
  520. <div className="flex items-center gap-1 no-drag">
  521. <button
  522. onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
  523. disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
  524. className={`p-1 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}
  525. title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}
  526. >
  527. <ChamberLight on={status?.chamber_light ?? false} className="w-3.5 h-3.5" />
  528. </button>
  529. <button
  530. onClick={() => setShowSkipObjectsModal(true)}
  531. disabled={!isPrintingWithObjects || !hasPermission('printers:control')}
  532. className={`p-1 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}
  533. title={
  534. !hasPermission('printers:control')
  535. ? t('printers.permission.noControl')
  536. : !isPrintingWithObjects
  537. ? t('printers.skipObjects.onlyWhilePrinting')
  538. : t('printers.skipObjects.tooltip')
  539. }
  540. >
  541. <SkipObjectsIcon className="w-3.5 h-3.5 text-bambu-gray" />
  542. </button>
  543. <button
  544. onClick={refresh}
  545. disabled={streamLoading || isReconnecting}
  546. className="p-1 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
  547. title="Refresh stream"
  548. >
  549. <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
  550. </button>
  551. <button
  552. onClick={() => setShowDiagnoseModal(true)}
  553. className="p-1 hover:bg-bambu-dark-tertiary rounded"
  554. title={t('camera.diagnose.button')}
  555. >
  556. <Stethoscope className="w-3.5 h-3.5 text-bambu-gray" />
  557. </button>
  558. <button
  559. onClick={toggleFullscreen}
  560. className="p-1 hover:bg-bambu-dark-tertiary rounded"
  561. title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
  562. >
  563. {isFullscreen ? (
  564. <Minimize className="w-3.5 h-3.5 text-bambu-gray" />
  565. ) : (
  566. <Fullscreen className="w-3.5 h-3.5 text-bambu-gray" />
  567. )}
  568. </button>
  569. <button
  570. onClick={() => setIsMinimized(!isMinimized)}
  571. className="p-1 hover:bg-bambu-dark-tertiary rounded"
  572. title={isMinimized ? 'Expand' : 'Minimize'}
  573. >
  574. {isMinimized ? (
  575. <Maximize2 className="w-3.5 h-3.5 text-bambu-gray" />
  576. ) : (
  577. <Minimize2 className="w-3.5 h-3.5 text-bambu-gray" />
  578. )}
  579. </button>
  580. <button
  581. onClick={onClose}
  582. className="p-1 hover:bg-red-500/20 rounded"
  583. title="Close"
  584. >
  585. <X className="w-3.5 h-3.5 text-bambu-gray hover:text-red-400" />
  586. </button>
  587. </div>
  588. </div>
  589. {/* Video area */}
  590. {!isMinimized && (
  591. <div
  592. className={`relative w-full bg-black flex items-center justify-center overflow-hidden ${isFullscreen ? 'h-[calc(100%-40px)]' : 'h-[calc(100%-40px)]'}`}
  593. onWheel={handleWheel}
  594. onMouseMove={handleImageMouseMove}
  595. onMouseUp={handleImageMouseUp}
  596. onMouseLeave={handleImageMouseUp}
  597. onTouchStart={handleTouchStart}
  598. onTouchMove={handleTouchMove}
  599. onTouchEnd={handleTouchEnd}
  600. style={{ touchAction: 'none' }}
  601. >
  602. {streamLoading && !isReconnecting && (
  603. <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
  604. <RefreshCw className="w-6 h-6 text-bambu-gray animate-spin" />
  605. </div>
  606. )}
  607. {isReconnecting && (
  608. <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
  609. <div className="text-center p-2">
  610. <WifiOff className="w-6 h-6 text-orange-400 mx-auto mb-2" />
  611. <p className="text-xs text-bambu-gray">
  612. Reconnecting in {reconnectCountdown}s...
  613. </p>
  614. </div>
  615. </div>
  616. )}
  617. {streamError && !isReconnecting && (
  618. <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
  619. <div className="text-center p-2">
  620. <AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
  621. <p className="text-xs text-bambu-gray mb-2">{t('camera.unavailable')}</p>
  622. <div className="flex gap-2 justify-center">
  623. <button
  624. onClick={refresh}
  625. className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
  626. >
  627. {t('camera.retry')}
  628. </button>
  629. <button
  630. onClick={() => setShowDiagnoseModal(true)}
  631. className="px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white rounded transition-colors"
  632. >
  633. {t('camera.diagnose.button')}
  634. </button>
  635. </div>
  636. </div>
  637. </div>
  638. )}
  639. <img
  640. ref={imgRef}
  641. key={imageKey}
  642. src={streamUrl}
  643. alt="Camera stream"
  644. className="max-w-full max-h-full object-contain select-none"
  645. style={{
  646. transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,
  647. ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100%', maxHeight: '100%' } : {}),
  648. cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
  649. }}
  650. onError={handleStreamError}
  651. onLoad={handleStreamLoad}
  652. onMouseDown={handleImageMouseDown}
  653. draggable={false}
  654. />
  655. {/* Zoom controls */}
  656. <div className="absolute bottom-2 left-2 flex items-center gap-1 bg-black/60 rounded px-1.5 py-1 no-drag">
  657. <button
  658. onClick={handleZoomOut}
  659. disabled={zoomLevel <= 1}
  660. className="p-1 hover:bg-white/10 rounded disabled:opacity-30"
  661. title="Zoom out"
  662. >
  663. <ZoomOut className="w-3.5 h-3.5 text-white" />
  664. </button>
  665. <button
  666. onClick={resetZoom}
  667. className="px-1.5 py-0.5 text-xs text-white hover:bg-white/10 rounded min-w-[32px]"
  668. title="Reset zoom"
  669. >
  670. {Math.round(zoomLevel * 100)}%
  671. </button>
  672. <button
  673. onClick={handleZoomIn}
  674. disabled={zoomLevel >= 4}
  675. className="p-1 hover:bg-white/10 rounded disabled:opacity-30"
  676. title="Zoom in"
  677. >
  678. <ZoomIn className="w-3.5 h-3.5 text-white" />
  679. </button>
  680. </div>
  681. {/* Resize handle - hide in fullscreen */}
  682. {!isFullscreen && (
  683. <div
  684. className="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors"
  685. onMouseDown={handleResizeMouseDown}
  686. onTouchStart={handleResizeTouchStart}
  687. title="Drag to resize"
  688. >
  689. <svg
  690. className="w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray"
  691. viewBox="0 0 24 24"
  692. fill="currentColor"
  693. >
  694. <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
  695. </svg>
  696. </div>
  697. )}
  698. </div>
  699. )}
  700. {/* Skip Objects Modal */}
  701. <SkipObjectsModal
  702. printerId={printerId}
  703. isOpen={showSkipObjectsModal}
  704. onClose={() => setShowSkipObjectsModal(false)}
  705. />
  706. {/* Camera diagnostic modal — opens from the error-state Diagnose button (#1395 follow-up) */}
  707. {showDiagnoseModal && (
  708. <CameraDiagnoseModal
  709. printerId={printerId}
  710. printerName={printer?.name || null}
  711. onClose={() => setShowDiagnoseModal(false)}
  712. />
  713. )}
  714. </div>
  715. );
  716. }