EmbeddedCameraViewer.tsx 29 KB

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