EmbeddedCameraViewer.tsx 27 KB

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