CameraPage.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  1. import { useState, useEffect, useRef, useCallback } from 'react';
  2. import { useParams } from 'react-router-dom';
  3. import { useQuery } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
  6. import { api } from '../api/client';
  7. const MAX_RECONNECT_ATTEMPTS = 5;
  8. const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
  9. const MAX_RECONNECT_DELAY = 30000; // 30 seconds
  10. const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
  11. export function CameraPage() {
  12. const { t } = useTranslation();
  13. const { printerId } = useParams<{ printerId: string }>();
  14. const id = parseInt(printerId || '0', 10);
  15. const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
  16. const [streamError, setStreamError] = useState(false);
  17. const [streamLoading, setStreamLoading] = useState(true);
  18. const [imageKey, setImageKey] = useState(Date.now());
  19. const [transitioning, setTransitioning] = useState(false);
  20. const [isFullscreen, setIsFullscreen] = useState(false);
  21. const [reconnectAttempts, setReconnectAttempts] = useState(0);
  22. const [isReconnecting, setIsReconnecting] = useState(false);
  23. const [reconnectCountdown, setReconnectCountdown] = useState(0);
  24. const [zoomLevel, setZoomLevel] = useState(1);
  25. const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
  26. const [isPanning, setIsPanning] = useState(false);
  27. const [panStart, setPanStart] = useState({ x: 0, y: 0 });
  28. const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
  29. const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
  30. const imgRef = useRef<HTMLImageElement>(null);
  31. const containerRef = useRef<HTMLDivElement>(null);
  32. const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
  33. const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
  34. const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
  35. // Fetch printer info for the title
  36. const { data: printer } = useQuery({
  37. queryKey: ['printer', id],
  38. queryFn: () => api.getPrinter(id),
  39. enabled: id > 0,
  40. });
  41. // Update document title
  42. useEffect(() => {
  43. if (printer) {
  44. document.title = `${printer.name} - Camera`;
  45. }
  46. return () => {
  47. document.title = 'Bambuddy';
  48. };
  49. }, [printer]);
  50. // Cleanup on unmount - stop the camera stream
  51. // Track if we've already sent the stop signal to avoid duplicate calls
  52. const stopSentRef = useRef(false);
  53. useEffect(() => {
  54. const stopUrl = `/api/v1/printers/${id}/camera/stop`;
  55. stopSentRef.current = false;
  56. const sendStopOnce = () => {
  57. if (id > 0 && !stopSentRef.current) {
  58. stopSentRef.current = true;
  59. navigator.sendBeacon(stopUrl);
  60. }
  61. };
  62. // Handle page unload/close with sendBeacon (more reliable than fetch on unload)
  63. const handleBeforeUnload = () => {
  64. sendStopOnce();
  65. };
  66. window.addEventListener('beforeunload', handleBeforeUnload);
  67. // Store ref value for cleanup - ref may change by cleanup time
  68. const imgElement = imgRef.current;
  69. return () => {
  70. window.removeEventListener('beforeunload', handleBeforeUnload);
  71. // Clear the image source first to stop the stream
  72. if (imgElement) {
  73. imgElement.src = '';
  74. }
  75. // Send stop signal only once
  76. sendStopOnce();
  77. };
  78. }, [id]);
  79. // Auto-hide loading after timeout
  80. useEffect(() => {
  81. if (streamLoading && !transitioning) {
  82. const timeout = streamMode === 'stream' ? 3000 : 20000;
  83. const timer = setTimeout(() => {
  84. setStreamLoading(false);
  85. }, timeout);
  86. return () => clearTimeout(timer);
  87. }
  88. }, [streamMode, streamLoading, imageKey, transitioning]);
  89. // Fullscreen change listener - refresh stream after fullscreen transition
  90. useEffect(() => {
  91. const handleFullscreenChange = () => {
  92. const nowFullscreen = !!document.fullscreenElement;
  93. setIsFullscreen(nowFullscreen);
  94. // Reset zoom on fullscreen transition
  95. setZoomLevel(1);
  96. setPanOffset({ x: 0, y: 0 });
  97. // Refresh stream after fullscreen transition to prevent stall
  98. if (streamMode === 'stream' && !transitioning) {
  99. // Clear image src first, then set new key after delay
  100. if (imgRef.current) {
  101. imgRef.current.src = '';
  102. }
  103. setTimeout(() => {
  104. setStreamLoading(true);
  105. setImageKey(Date.now());
  106. }, 200);
  107. }
  108. };
  109. document.addEventListener('fullscreenchange', handleFullscreenChange);
  110. return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
  111. }, [streamMode, transitioning]);
  112. // Save window size and position when user resizes or moves
  113. // Works for both popup windows and standalone camera pages
  114. useEffect(() => {
  115. let saveTimeout: NodeJS.Timeout;
  116. const saveWindowState = () => {
  117. // Debounce to avoid saving during drag
  118. clearTimeout(saveTimeout);
  119. saveTimeout = setTimeout(() => {
  120. localStorage.setItem('cameraWindowState', JSON.stringify({
  121. width: window.outerWidth,
  122. height: window.outerHeight,
  123. left: window.screenX,
  124. top: window.screenY,
  125. }));
  126. }, 500);
  127. };
  128. window.addEventListener('resize', saveWindowState);
  129. return () => {
  130. clearTimeout(saveTimeout);
  131. window.removeEventListener('resize', saveWindowState);
  132. };
  133. }, []);
  134. // Clean up reconnect timers on unmount
  135. useEffect(() => {
  136. return () => {
  137. if (reconnectTimerRef.current) {
  138. clearTimeout(reconnectTimerRef.current);
  139. }
  140. if (countdownIntervalRef.current) {
  141. clearInterval(countdownIntervalRef.current);
  142. }
  143. if (stallCheckIntervalRef.current) {
  144. clearInterval(stallCheckIntervalRef.current);
  145. }
  146. };
  147. }, []);
  148. // Auto-reconnect logic
  149. const attemptReconnect = useCallback(() => {
  150. if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
  151. setIsReconnecting(false);
  152. setStreamError(true);
  153. return;
  154. }
  155. // Calculate delay with exponential backoff
  156. const delay = Math.min(
  157. INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),
  158. MAX_RECONNECT_DELAY
  159. );
  160. setIsReconnecting(true);
  161. setReconnectCountdown(Math.ceil(delay / 1000));
  162. // Countdown timer
  163. countdownIntervalRef.current = setInterval(() => {
  164. setReconnectCountdown((prev) => {
  165. if (prev <= 1) {
  166. if (countdownIntervalRef.current) {
  167. clearInterval(countdownIntervalRef.current);
  168. }
  169. return 0;
  170. }
  171. return prev - 1;
  172. });
  173. }, 1000);
  174. // Reconnect after delay
  175. reconnectTimerRef.current = setTimeout(() => {
  176. setReconnectAttempts((prev) => prev + 1);
  177. setIsReconnecting(false);
  178. setStreamLoading(true);
  179. setStreamError(false);
  180. if (imgRef.current) {
  181. imgRef.current.src = '';
  182. }
  183. setImageKey(Date.now());
  184. }, delay);
  185. }, [reconnectAttempts]);
  186. // Stall detection - periodically check if stream is still receiving frames
  187. useEffect(() => {
  188. // Only skip stall check during initial load, reconnecting, or transitioning
  189. // Continue checking even during streamError to detect recovery
  190. if (streamMode !== 'stream' || streamLoading || isReconnecting || transitioning) {
  191. if (stallCheckIntervalRef.current) {
  192. clearInterval(stallCheckIntervalRef.current);
  193. stallCheckIntervalRef.current = null;
  194. }
  195. return;
  196. }
  197. // Start stall detection after stream has loaded
  198. stallCheckIntervalRef.current = setInterval(async () => {
  199. try {
  200. const status = await api.getCameraStatus(id);
  201. // Trigger reconnect if:
  202. // 1. Backend reports stall (no frames for 10+ seconds)
  203. // 2. OR stream is not active anymore (process died)
  204. if (status.stalled || (!status.active && !streamError)) {
  205. console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
  206. if (stallCheckIntervalRef.current) {
  207. clearInterval(stallCheckIntervalRef.current);
  208. stallCheckIntervalRef.current = null;
  209. }
  210. setStreamLoading(false);
  211. attemptReconnect();
  212. }
  213. } catch {
  214. // Ignore fetch errors - server might be temporarily unavailable
  215. }
  216. }, STALL_CHECK_INTERVAL);
  217. return () => {
  218. if (stallCheckIntervalRef.current) {
  219. clearInterval(stallCheckIntervalRef.current);
  220. stallCheckIntervalRef.current = null;
  221. }
  222. };
  223. }, [streamMode, streamLoading, streamError, isReconnecting, transitioning, id, attemptReconnect]);
  224. const handleStreamError = () => {
  225. setStreamLoading(false);
  226. // Only auto-reconnect for live stream mode
  227. if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
  228. attemptReconnect();
  229. } else {
  230. setStreamError(true);
  231. }
  232. };
  233. const handleStreamLoad = () => {
  234. setStreamLoading(false);
  235. setStreamError(false);
  236. // Reset reconnect attempts on successful connection
  237. setReconnectAttempts(0);
  238. setIsReconnecting(false);
  239. if (reconnectTimerRef.current) {
  240. clearTimeout(reconnectTimerRef.current);
  241. }
  242. if (countdownIntervalRef.current) {
  243. clearInterval(countdownIntervalRef.current);
  244. }
  245. // Auto-resize window to fit video content (only if no saved preference)
  246. if (imgRef.current && !localStorage.getItem('cameraWindowState')) {
  247. const img = imgRef.current;
  248. const videoWidth = img.naturalWidth;
  249. const videoHeight = img.naturalHeight;
  250. if (videoWidth > 0 && videoHeight > 0) {
  251. // Add space for header bar (~45px) and some padding
  252. const headerHeight = 45;
  253. const padding = 16;
  254. // Calculate window size (outer size includes chrome)
  255. const chromeWidth = window.outerWidth - window.innerWidth;
  256. const chromeHeight = window.outerHeight - window.innerHeight;
  257. const targetWidth = videoWidth + padding + chromeWidth;
  258. const targetHeight = videoHeight + headerHeight + padding + chromeHeight;
  259. try {
  260. window.resizeTo(targetWidth, targetHeight);
  261. } catch {
  262. // resizeTo may not be allowed in all contexts
  263. }
  264. }
  265. }
  266. };
  267. const stopStream = () => {
  268. if (id > 0) {
  269. fetch(`/api/v1/printers/${id}/camera/stop`).catch(() => {});
  270. }
  271. };
  272. const switchToMode = (newMode: 'stream' | 'snapshot') => {
  273. if (streamMode === newMode || transitioning) return;
  274. setTransitioning(true);
  275. setStreamLoading(true);
  276. setStreamError(false);
  277. // Reset reconnect state on mode switch
  278. setReconnectAttempts(0);
  279. setIsReconnecting(false);
  280. // Reset zoom on mode switch
  281. setZoomLevel(1);
  282. setPanOffset({ x: 0, y: 0 });
  283. if (reconnectTimerRef.current) {
  284. clearTimeout(reconnectTimerRef.current);
  285. }
  286. if (countdownIntervalRef.current) {
  287. clearInterval(countdownIntervalRef.current);
  288. }
  289. if (imgRef.current) {
  290. imgRef.current.src = '';
  291. }
  292. // Stop any active streams when switching modes
  293. if (streamMode === 'stream') {
  294. stopStream();
  295. }
  296. setTimeout(() => {
  297. setStreamMode(newMode);
  298. setImageKey(Date.now());
  299. setTransitioning(false);
  300. }, 100);
  301. };
  302. const refresh = () => {
  303. if (transitioning) return;
  304. setTransitioning(true);
  305. setStreamLoading(true);
  306. setStreamError(false);
  307. // Reset reconnect state on manual refresh
  308. setReconnectAttempts(0);
  309. setIsReconnecting(false);
  310. if (reconnectTimerRef.current) {
  311. clearTimeout(reconnectTimerRef.current);
  312. }
  313. if (countdownIntervalRef.current) {
  314. clearInterval(countdownIntervalRef.current);
  315. }
  316. if (imgRef.current) {
  317. imgRef.current.src = '';
  318. }
  319. // Stop any active streams before refresh
  320. if (streamMode === 'stream') {
  321. stopStream();
  322. }
  323. setTimeout(() => {
  324. setImageKey(Date.now());
  325. setTransitioning(false);
  326. }, 100);
  327. };
  328. const toggleFullscreen = () => {
  329. if (!containerRef.current) return;
  330. if (document.fullscreenElement) {
  331. document.exitFullscreen();
  332. } else {
  333. containerRef.current.requestFullscreen();
  334. }
  335. };
  336. const handleZoomIn = () => {
  337. setZoomLevel(prev => Math.min(prev + 0.5, 4));
  338. };
  339. const handleZoomOut = () => {
  340. setZoomLevel(prev => {
  341. const newZoom = Math.max(prev - 0.5, 1);
  342. if (newZoom === 1) setPanOffset({ x: 0, y: 0 });
  343. return newZoom;
  344. });
  345. };
  346. const handleWheel = (e: React.WheelEvent) => {
  347. e.preventDefault();
  348. if (e.deltaY < 0) {
  349. handleZoomIn();
  350. } else {
  351. handleZoomOut();
  352. }
  353. };
  354. const handleImageMouseDown = (e: React.MouseEvent) => {
  355. if (zoomLevel > 1) {
  356. e.preventDefault();
  357. setIsPanning(true);
  358. setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
  359. }
  360. };
  361. // Calculate max pan based on container size and zoom level
  362. const getMaxPan = useCallback(() => {
  363. if (!containerRef.current) {
  364. return { x: 300, y: 200 };
  365. }
  366. const container = containerRef.current.getBoundingClientRect();
  367. // Allow panning up to half the zoomed overflow in each direction
  368. const maxX = (container.width * (zoomLevel - 1)) / 2;
  369. const maxY = (container.height * (zoomLevel - 1)) / 2;
  370. return { x: Math.max(50, maxX), y: Math.max(50, maxY) };
  371. }, [zoomLevel]);
  372. const handleImageMouseMove = (e: React.MouseEvent) => {
  373. if (isPanning && zoomLevel > 1) {
  374. const newX = e.clientX - panStart.x;
  375. const newY = e.clientY - panStart.y;
  376. const maxPan = getMaxPan();
  377. setPanOffset({
  378. x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
  379. y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
  380. });
  381. }
  382. };
  383. const handleImageMouseUp = () => {
  384. setIsPanning(false);
  385. };
  386. // Touch event handlers for mobile
  387. const getTouchDistance = (touches: React.TouchList) => {
  388. if (touches.length < 2) return 0;
  389. const dx = touches[0].clientX - touches[1].clientX;
  390. const dy = touches[0].clientY - touches[1].clientY;
  391. return Math.sqrt(dx * dx + dy * dy);
  392. };
  393. const getTouchCenter = (touches: React.TouchList) => {
  394. if (touches.length < 2) {
  395. return { x: touches[0].clientX, y: touches[0].clientY };
  396. }
  397. return {
  398. x: (touches[0].clientX + touches[1].clientX) / 2,
  399. y: (touches[0].clientY + touches[1].clientY) / 2,
  400. };
  401. };
  402. const handleTouchStart = (e: React.TouchEvent) => {
  403. if (e.touches.length === 2) {
  404. // Pinch gesture start
  405. e.preventDefault();
  406. setLastTouchDistance(getTouchDistance(e.touches));
  407. setLastTouchCenter(getTouchCenter(e.touches));
  408. } else if (e.touches.length === 1 && zoomLevel > 1) {
  409. // Single touch pan start
  410. e.preventDefault();
  411. setIsPanning(true);
  412. setPanStart({
  413. x: e.touches[0].clientX - panOffset.x,
  414. y: e.touches[0].clientY - panOffset.y,
  415. });
  416. }
  417. };
  418. const handleTouchMove = (e: React.TouchEvent) => {
  419. if (e.touches.length === 2 && lastTouchDistance !== null) {
  420. // Pinch gesture
  421. e.preventDefault();
  422. const newDistance = getTouchDistance(e.touches);
  423. const scale = newDistance / lastTouchDistance;
  424. setZoomLevel(prev => {
  425. const newZoom = Math.max(1, Math.min(4, prev * scale));
  426. if (newZoom === 1) {
  427. setPanOffset({ x: 0, y: 0 });
  428. }
  429. return newZoom;
  430. });
  431. setLastTouchDistance(newDistance);
  432. // Also handle pan during pinch
  433. const newCenter = getTouchCenter(e.touches);
  434. if (lastTouchCenter) {
  435. const maxPan = getMaxPan();
  436. setPanOffset(prev => ({
  437. x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),
  438. y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),
  439. }));
  440. }
  441. setLastTouchCenter(newCenter);
  442. } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {
  443. // Single touch pan
  444. e.preventDefault();
  445. const newX = e.touches[0].clientX - panStart.x;
  446. const newY = e.touches[0].clientY - panStart.y;
  447. const maxPan = getMaxPan();
  448. setPanOffset({
  449. x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
  450. y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
  451. });
  452. }
  453. };
  454. const handleTouchEnd = (e: React.TouchEvent) => {
  455. if (e.touches.length < 2) {
  456. setLastTouchDistance(null);
  457. setLastTouchCenter(null);
  458. }
  459. if (e.touches.length === 0) {
  460. setIsPanning(false);
  461. }
  462. };
  463. const resetZoom = () => {
  464. setZoomLevel(1);
  465. setPanOffset({ x: 0, y: 0 });
  466. };
  467. const currentUrl = transitioning
  468. ? ''
  469. : streamMode === 'stream'
  470. ? `/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`
  471. : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
  472. const isDisabled = streamLoading || transitioning || isReconnecting;
  473. if (!id) {
  474. return (
  475. <div className="min-h-screen bg-black flex items-center justify-center">
  476. <p className="text-white">{t('camera.invalidPrinterId')}</p>
  477. </div>
  478. );
  479. }
  480. return (
  481. <div ref={containerRef} className="min-h-screen bg-black flex flex-col">
  482. {/* Header */}
  483. <div className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
  484. <h1 className="text-sm font-medium text-white flex items-center gap-2">
  485. <Camera className="w-4 h-4" />
  486. {printer?.name || `Printer ${id}`}
  487. </h1>
  488. <div className="flex items-center gap-2">
  489. {/* Mode toggle */}
  490. <div className="flex bg-bambu-dark rounded p-0.5">
  491. <button
  492. onClick={() => switchToMode('stream')}
  493. disabled={isDisabled}
  494. className={`px-3 py-1 text-xs rounded transition-colors ${
  495. streamMode === 'stream'
  496. ? 'bg-bambu-green text-white'
  497. : 'text-bambu-gray hover:text-white disabled:opacity-50'
  498. }`}
  499. >
  500. {t('camera.live')}
  501. </button>
  502. <button
  503. onClick={() => switchToMode('snapshot')}
  504. disabled={isDisabled}
  505. className={`px-3 py-1 text-xs rounded transition-colors ${
  506. streamMode === 'snapshot'
  507. ? 'bg-bambu-green text-white'
  508. : 'text-bambu-gray hover:text-white disabled:opacity-50'
  509. }`}
  510. >
  511. {t('camera.snapshot')}
  512. </button>
  513. </div>
  514. <button
  515. onClick={refresh}
  516. disabled={isDisabled}
  517. className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
  518. title={streamMode === 'stream' ? t('camera.restartStream') : t('camera.refreshSnapshot')}
  519. >
  520. <RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
  521. </button>
  522. <button
  523. onClick={toggleFullscreen}
  524. className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
  525. title={isFullscreen ? t('camera.exitFullscreen') : t('camera.fullscreen')}
  526. >
  527. {isFullscreen ? (
  528. <Minimize className="w-4 h-4 text-bambu-gray" />
  529. ) : (
  530. <Maximize className="w-4 h-4 text-bambu-gray" />
  531. )}
  532. </button>
  533. </div>
  534. </div>
  535. {/* Video area */}
  536. <div
  537. className="flex-1 flex items-center justify-center p-2 overflow-hidden"
  538. onWheel={handleWheel}
  539. onMouseMove={handleImageMouseMove}
  540. onMouseUp={handleImageMouseUp}
  541. onMouseLeave={handleImageMouseUp}
  542. onTouchStart={handleTouchStart}
  543. onTouchMove={handleTouchMove}
  544. onTouchEnd={handleTouchEnd}
  545. style={{ touchAction: 'none' }}
  546. >
  547. <div className="relative w-full h-full flex items-center justify-center">
  548. {(streamLoading || transitioning) && !isReconnecting && (
  549. <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
  550. <div className="text-center">
  551. <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
  552. <p className="text-sm text-bambu-gray">
  553. {streamMode === 'stream' ? t('camera.connectingToCamera') : t('camera.capturingSnapshot')}
  554. </p>
  555. </div>
  556. </div>
  557. )}
  558. {isReconnecting && (
  559. <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
  560. <div className="text-center p-4">
  561. <WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
  562. <p className="text-white mb-2">{t('camera.connectionLost')}</p>
  563. <p className="text-sm text-bambu-gray mb-3">
  564. {t('camera.reconnecting', { countdown: reconnectCountdown, attempt: reconnectAttempts + 1, max: MAX_RECONNECT_ATTEMPTS })}
  565. </p>
  566. <button
  567. onClick={refresh}
  568. className="px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors"
  569. >
  570. {t('camera.reconnectNow')}
  571. </button>
  572. </div>
  573. </div>
  574. )}
  575. {streamError && !isReconnecting && (
  576. <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
  577. <div className="text-center p-4">
  578. <AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
  579. <p className="text-white mb-2">{t('camera.cameraUnavailable')}</p>
  580. <p className="text-xs text-bambu-gray mb-4 max-w-md">
  581. {t('camera.cameraUnavailableDesc')}
  582. </p>
  583. <button
  584. onClick={refresh}
  585. className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
  586. >
  587. {t('camera.retry')}
  588. </button>
  589. </div>
  590. </div>
  591. )}
  592. <img
  593. ref={imgRef}
  594. key={imageKey}
  595. src={currentUrl}
  596. alt={t('camera.cameraStream')}
  597. className="max-w-full max-h-full object-contain select-none"
  598. style={{
  599. transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
  600. cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
  601. }}
  602. onError={currentUrl ? handleStreamError : undefined}
  603. onLoad={currentUrl ? handleStreamLoad : undefined}
  604. onMouseDown={handleImageMouseDown}
  605. draggable={false}
  606. />
  607. {/* Zoom controls */}
  608. <div className="absolute bottom-4 left-4 flex items-center gap-1.5 bg-black/60 rounded-lg px-2 py-1.5">
  609. <button
  610. onClick={handleZoomOut}
  611. disabled={zoomLevel <= 1}
  612. className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
  613. title={t('camera.zoomOut')}
  614. >
  615. <ZoomOut className="w-4 h-4 text-white" />
  616. </button>
  617. <button
  618. onClick={resetZoom}
  619. className="px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]"
  620. title={t('camera.resetZoom')}
  621. >
  622. {Math.round(zoomLevel * 100)}%
  623. </button>
  624. <button
  625. onClick={handleZoomIn}
  626. disabled={zoomLevel >= 4}
  627. className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
  628. title={t('camera.zoomIn')}
  629. >
  630. <ZoomIn className="w-4 h-4 text-white" />
  631. </button>
  632. </div>
  633. </div>
  634. </div>
  635. </div>
  636. );
  637. }