EmbeddedCameraViewer.tsx 31 KB

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