Procházet zdrojové kódy

fix(camera): closing or minimizing a camera view ends its stream

A detached img keeps its MJPEG connection open. The camera window on the
Printers page left its stream open when minimized, and both it and the
Camera page cleared only the img they first showed, so after a reconnect
or restart closing them left the current stream running: the printer's
camera connection stayed busy, the chamber light stayed on and the stop
request on closing was skipped. A shared hook (useStreamImgRef, also used
by the Cam Wall tile) removes the src of every img a view drops, once the
img has left the page (StrictMode-safe). The viewers ignore the error an
emptied src fires, so a reconnect no longer counts an extra failed attempt.
maziggy před 1 týdnem
rodič
revize
038738fcbf
64 změnil soubory, kde provedl 207 přidání a 44 odebrání
  1. 1 0
      CHANGELOG.md
  2. 85 0
      frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx
  3. 57 3
      frontend/src/__tests__/pages/CameraPage.test.tsx
  4. 7 17
      frontend/src/components/CameraTile.tsx
  5. 9 8
      frontend/src/components/EmbeddedCameraViewer.tsx
  6. 35 0
      frontend/src/hooks/useStreamImgRef.ts
  7. 9 11
      frontend/src/pages/CameraPage.tsx
  8. 0 0
      static/assets/AdditionalSection-D_Hs7ga0.js
  9. 0 0
      static/assets/AmsUnitCard-BlibgKmd.js
  10. 0 0
      static/assets/ArchivesPage-RdkckclR.js
  11. 0 0
      static/assets/AssignToAmsModal-CkKkmT-k.js
  12. 0 0
      static/assets/CamWallPage-MvzF5-W5.js
  13. 0 0
      static/assets/CameraDiagnoseModal-B8tzQ5r7.js
  14. 0 0
      static/assets/CameraPage-DvMFfXde.js
  15. 0 0
      static/assets/CameraPage-UKhKNqXC.js
  16. 0 0
      static/assets/CameraWall-Cx-yAK74.js
  17. 0 0
      static/assets/CameraWall-D5tCGkia.js
  18. 0 0
      static/assets/ColumnConfigModal-CHWt_9zL.js
  19. 0 0
      static/assets/ConfigureAmsSlotModal-DKOr3s81.js
  20. 0 0
      static/assets/ConnectAuthorizePage-w_X_jeHo.js
  21. 0 0
      static/assets/ContextMenu-CXDQbNZ-.js
  22. 0 0
      static/assets/ExternalLinkPage-Dq91pEIF.js
  23. 0 1
      static/assets/FileManagerPage-D_LM_o4f.js
  24. 0 0
      static/assets/FileUploadModal-CX5sSlUQ.js
  25. 0 0
      static/assets/FinancePage-Bobn6qEc.js
  26. 0 0
      static/assets/GCodeViewerPage-DjiHJW7A.js
  27. 0 0
      static/assets/GroupEditPage-B834Oi8w.js
  28. 0 0
      static/assets/ImagePreviewModal-DABZ_mCG.js
  29. 0 0
      static/assets/InventoryPage-DcHlbZnn.js
  30. 0 0
      static/assets/LibraryTrashPage-DK3ahQIR.js
  31. 0 0
      static/assets/LoginPage-Dn1CdNkk.js
  32. 0 0
      static/assets/MaintenancePage-BfDONORn.js
  33. 0 0
      static/assets/ModelSourcesPage-CVNMpxJ5.js
  34. 0 0
      static/assets/NotificationsPage-qQ-oBdol.js
  35. 0 1
      static/assets/PdfPreviewModal-Bn8pMaZA.js
  36. 0 0
      static/assets/PhotoGalleryModal-DpNF7SvR.js
  37. 0 0
      static/assets/PrinterLocationsPage-CbreDUzS.js
  38. 0 0
      static/assets/PrintersPage-DfIFywvM.js
  39. 0 0
      static/assets/PrintersPage-XKILhdfY.js
  40. 0 0
      static/assets/ProfilesPage-CRfcGl8j.js
  41. 0 0
      static/assets/ProjectDetailPage-CoreoL0j.js
  42. 0 0
      static/assets/ProjectsPage-BqNRVw6s.js
  43. 0 0
      static/assets/QueuePage-QDl5KC-l.js
  44. 0 0
      static/assets/RichTextEditor-BvX5KokE.js
  45. 0 0
      static/assets/SettingsPage-DrjKlvxW.js
  46. 0 0
      static/assets/SetupPage-DJq0NUU_.js
  47. 0 0
      static/assets/SliceModal-BUQHgTI0.js
  48. 0 0
      static/assets/SpoolBuddyAmsPage-COi2tLEu.js
  49. 0 0
      static/assets/SpoolBuddyCalibrationPage-9mn3MgNw.js
  50. 0 0
      static/assets/SpoolBuddyDashboard-CzZNEPLC.js
  51. 0 0
      static/assets/SpoolBuddyInventoryPage-KmbI9U-e.js
  52. 1 1
      static/assets/SpoolBuddySettingsPage-DY7zeBdM.js
  53. 0 0
      static/assets/SpoolBuddyWriteTagPage-BwnYb-lK.js
  54. 0 0
      static/assets/SpreadsheetPreviewModal-D-TUvcT6.js
  55. 0 0
      static/assets/StatsPage-DiN6uSXT.js
  56. 0 0
      static/assets/StreamOverlayPage-CI1u4zAZ.js
  57. 0 0
      static/assets/SystemInfoPage-D6XFwHG8.js
  58. 0 0
      static/assets/index-B-1xu8EB.js
  59. 1 1
      static/assets/inventorySearch-C5Ldfq0w.js
  60. 0 0
      static/assets/locationSensorDefaults-ByuoRaCK.js
  61. 0 0
      static/assets/pdf-DN0tUP9U.js
  62. 1 0
      static/assets/useStreamImgRef-CRYQNmK0.js
  63. 0 0
      static/assets/utils-BnTZfYVQ.js
  64. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Closing or minimizing a camera view now ends its stream** — The camera window on the Printers page left its stream open when minimized (one more open connection per minimize and expand), and both it and the Camera page closed only the first picture they had shown: after a reconnect or a restart, closing them left the current stream running. An open stream keeps the printer's camera connection busy, keeps the chamber light on when "Turn on chamber light for camera" is on for that printer, and made the "stop camera" request on closing do nothing. Every picture a camera view drops now ends its stream. A reconnect also no longer counts one extra failed attempt toward the reconnect limit.
 - **Cam Wall tiles no longer freeze on their last frame** — When the server ended a live stream (the camera dropped, or a brief database hiccup during the stream's 30-second access check), browsers report nothing, so a Cam Wall tile kept showing its last frame until the page was reloaded. Live tiles now reconnect their stream every minute while the page is visible, so a camera that comes back shows a picture again within a minute. If the camera stays down, the tile shows "No signal" (a printer's built-in camera keeps its last picture until Bambuddy stops retrying it). A tile that leaves live mode (scrolled away, paused) now closes its stream too, instead of leaving the connection open. The streaming overlay, which already renewed its camera every minute, now closes the old connection first: a browser source left running for hours piled up one open camera connection per minute. A single failed access check no longer ends a stream either: it is checked again after 5 seconds, and only a second failure in a row ends it.
 - **SQLite no longer gives a deleted user's, API key's, printer's or archive's number to a new one** — SQLite reuses the highest freed id, so whatever still referred to a deleted user, API key, printer, print archive or camera token could end up referring to the next one created. Earlier fixes cleaned up after each such case; these tables now never reuse an id at all. Existing SQLite databases are converted once at start (the rows, columns and indexes are kept), and ids still referred to (a deleted printer's print history, for example) are skipped. PostgreSQL never reused ids itself; a SQLite backup restored on PostgreSQL (including one restored before this release), or a PostgreSQL backup restored on SQLite, now keeps those ids unused too.
 - **Taking away live updates or camera access through a group now ends them** — Only creating a dashboard's live connection or a camera link checked the permission for it. When an admin removed that permission from a group, removed a user from the group that granted it, or turned off an API key's status access, a dashboard already open kept receiving live updates and could reconnect for up to an hour, and an open camera view kept running, its link opened new views and snapshots, and a Cam Wall or Home Assistant camera token kept working until it expired. Those connections and views are now closed and the links refused once the permission is gone; the dashboard falls back to refreshing on its own as it does for users without live updates.

+ 85 - 0
frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx

@@ -0,0 +1,85 @@
+import { StrictMode } from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { EmbeddedCameraViewer } from '../../components/EmbeddedCameraViewer';
+
+// A detached img keeps its MJPEG connection open (measured in Chromium): the
+// backend stream, the chamber light it holds and the /camera/stop it blocks
+// stay until the browser closes. Every img the viewer drops has its src removed.
+
+async function settle() {
+  await act(async () => {
+    await Promise.resolve();
+  });
+}
+
+function viewer() {
+  return <EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={() => {}} />;
+}
+
+describe('EmbeddedCameraViewer stream lifetime', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    server.use(
+      http.post('/api/v1/printers/:id/camera/stop', () => HttpResponse.json({ success: true })),
+      http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })),
+    );
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('closes the stream when the viewer is minimized', async () => {
+    render(viewer());
+    await settle();
+    const img = screen.getByAltText('Camera stream');
+    expect(img.getAttribute('src')).toContain('/api/v1/printers/7/camera/stream');
+
+    fireEvent.click(screen.getByTitle('Minimize'));
+    await settle();
+
+    expect(img.getAttribute('src')).toBeNull();
+  });
+
+  it('closes the current stream on close after the stream was restarted', async () => {
+    const { unmount } = render(viewer());
+    await settle();
+    const first = screen.getByAltText('Camera stream');
+    fireEvent.load(first);
+    await waitFor(() => expect(screen.getByTitle('Refresh stream')).not.toBeDisabled());
+
+    fireEvent.click(screen.getByTitle('Refresh stream'));
+    await waitFor(() => expect(screen.getByAltText('Camera stream')).not.toBe(first));
+    const current = screen.getByAltText('Camera stream');
+    expect(current.getAttribute('src')).toContain('/camera/stream');
+
+    unmount();
+    await settle();
+
+    expect(current.getAttribute('src')).toBeNull();
+  });
+
+  it('takes the error of a closed img for no stream failure', async () => {
+    render(viewer());
+    await settle();
+    const img = screen.getByAltText('Camera stream');
+
+    await act(async () => {
+      img.setAttribute('src', ''); // what the reconnect and restart code does
+      img.dispatchEvent(new Event('error'));
+    });
+
+    expect(screen.queryByText(/Reconnecting in/)).toBeNull();
+  });
+
+  it('keeps its stream under StrictMode', async () => {
+    render(<StrictMode>{viewer()}</StrictMode>);
+    await settle();
+
+    expect(screen.getByAltText('Camera stream').getAttribute('src')).toContain('/api/v1/printers/7/camera/stream');
+  });
+});

+ 57 - 3
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -2,8 +2,9 @@
  * Tests for the CameraPage component.
  */
 
+import { StrictMode } from 'react';
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { act, fireEvent, screen, waitFor, render as rtlRender } from '@testing-library/react';
 import { CameraPage } from '../../pages/CameraPage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
@@ -32,7 +33,7 @@ const mockPrinter = {
 };
 
 // Custom render for CameraPage which needs specific route params
-function renderCameraPage(printerId: number, search = '') {
+function renderCameraPage(printerId: number, search = '', strict = false) {
   const queryClient = new QueryClient({
     defaultOptions: {
       queries: { retry: false, gcTime: 0 },
@@ -40,7 +41,7 @@ function renderCameraPage(printerId: number, search = '') {
     },
   });
 
-  return rtlRender(
+  const tree = (
     <QueryClientProvider client={queryClient}>
       <I18nextProvider i18n={i18n}>
         <MemoryRouter initialEntries={[`/cameras/${printerId}${search}`]}>
@@ -57,6 +58,7 @@ function renderCameraPage(printerId: number, search = '') {
       </I18nextProvider>
     </QueryClientProvider>
   );
+  return rtlRender(strict ? <StrictMode>{tree}</StrictMode> : tree);
 }
 
 describe('CameraPage', () => {
@@ -138,6 +140,58 @@ describe('CameraPage', () => {
     });
   });
 
+  // A detached img keeps its MJPEG connection open (measured in Chromium): every
+  // img the page drops has its src removed, not only the one it first showed.
+  describe('stream lifetime', () => {
+    async function streamImg() {
+      await waitFor(() => {
+        expect((document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '').toContain('/camera/stream');
+      });
+      return document.querySelector('img') as HTMLImageElement;
+    }
+
+    it('closes the current stream on leaving after the stream was restarted', async () => {
+      const { unmount } = renderCameraPage(1);
+      const first = await streamImg();
+      fireEvent.load(first);
+
+      await waitFor(() => expect(screen.getByTitle('Restart stream')).not.toBeDisabled());
+      fireEvent.click(screen.getByTitle('Restart stream'));
+      await waitFor(() => expect(document.querySelector('img')).not.toBe(first));
+      const current = await streamImg();
+
+      unmount();
+      await act(async () => {
+        await Promise.resolve();
+      });
+
+      expect(current.getAttribute('src')).toBeNull();
+    });
+
+    it('takes the error of a closed img for no stream failure', async () => {
+      renderCameraPage(1);
+      const img = await streamImg();
+
+      await act(async () => {
+        img.setAttribute('src', ''); // what the reconnect and restart code does
+        img.dispatchEvent(new Event('error'));
+      });
+
+      expect(screen.queryByText(/Reconnecting in/)).toBeNull();
+    });
+
+    it('keeps its stream under StrictMode', async () => {
+      renderCameraPage(1, '', true);
+
+      const img = await streamImg();
+      await act(async () => {
+        await Promise.resolve();
+      });
+
+      expect(img.getAttribute('src')).toContain('/camera/stream');
+    });
+  });
+
   describe('stream token handling (#979)', () => {
     it('does not render image src until stream token arrives when auth is enabled', async () => {
       let resolveToken!: (value: unknown) => void;

+ 7 - 17
frontend/src/components/CameraTile.tsx

@@ -1,8 +1,9 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react';
 import { getAuthToken, withStreamToken } from '../api/client';
 import { formatDuration } from '../utils/date';
+import { useStreamImgRef } from '../hooks/useStreamImgRef';
 
 export type CameraTileMode = 'live' | 'snapshot' | 'paused';
 export type CameraTileStatusMode = 'off' | 'compact' | 'full';
@@ -90,22 +91,11 @@ export function CameraTile({
   // no src (removing it closes the connection), so no re-render reopens it early.
   const [reopening, setReopening] = useState(false);
   const lastModeRef = useRef<CameraTileMode>(mode);
-  const imgRef = useRef<HTMLImageElement | null>(null);
   // Every img this tile drops (leaving live mode, a renewal, unmounting) has
-  // its stream closed: a detached img keeps its MJPEG connection open, and the
-  // /camera/stop sent on leaving live mode is skipped while it does.
-  const attachImg = useCallback((img: HTMLImageElement | null) => {
-    imgRef.current = img;
-    if (!img) return;
-    return () => {
-      if (imgRef.current === img) imgRef.current = null;
-      // Once the commit is done: React runs this with the img still in the
-      // page, and in development (StrictMode) also for an img that stays.
-      queueMicrotask(() => {
-        if (!img.isConnected) img.removeAttribute('src');
-      });
-    };
-  }, []);
+  // its stream closed; the /camera/stop sent on leaving live mode is skipped
+  // while a dropped img still holds it.
+  const imgRef = useRef<HTMLImageElement | null>(null);
+  const attachImg = useStreamImgRef(imgRef);
   // 'renewed': the current img is a renewal not yet showing a frame; 'retried':
   // it is the one retry of a renewal that failed.
   const renewalRef = useRef<'none' | 'renewed' | 'retried'>('none');
@@ -284,7 +274,7 @@ export function CameraTile({
             renewalRef.current = 'none';
           }}
           onError={(e) => {
-            // Closing the old stream (src removed) fires error too.
+            // An img with an empty or missing src is closed, not failed.
             if (!e.currentTarget.getAttribute('src')) return;
             streamFailed();
           }}

+ 9 - 8
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -8,6 +8,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { ChamberLight } from './icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
 import { CameraDiagnoseModal } from './CameraDiagnoseModal';
+import { useStreamImgRef } from '../hooks/useStreamImgRef';
 
 interface EmbeddedCameraViewerProps {
   printerId: number;
@@ -93,7 +94,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const [reconnectCountdown, setReconnectCountdown] = useState(0);
 
   const containerRef = useRef<HTMLDivElement>(null);
-  const imgRef = useRef<HTMLImageElement>(null);
+  // Every img the viewer drops (minimized, re-keyed by a reconnect, closed)
+  // has its stream closed.
+  const imgRef = useRef<HTMLImageElement | null>(null);
+  const attachImg = useStreamImgRef(imgRef);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -168,12 +172,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
       }
     };
 
-    const imgElement = imgRef.current;
-
     return () => {
-      if (imgElement) {
-        imgElement.src = '';
-      }
       sendStopOnce();
       if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
       if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
@@ -430,7 +429,9 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     setPanOffset({ x: 0, y: 0 });
   };
 
-  const handleStreamError = () => {
+  const handleStreamError = (e: React.SyntheticEvent<HTMLImageElement>) => {
+    // Clearing the src (reconnect, Refresh) fires error with an empty src.
+    if (!e.currentTarget.getAttribute('src')) return;
     setStreamLoading(false);
     if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
       attemptReconnect();
@@ -700,7 +701,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             </div>
           )}
           <img
-            ref={imgRef}
+            ref={attachImg}
             key={imageKey}
             src={streamUrl}
             alt="Camera stream"

+ 35 - 0
frontend/src/hooks/useStreamImgRef.ts

@@ -0,0 +1,35 @@
+import { useCallback, type MutableRefObject } from 'react';
+
+/**
+ * A ref for a camera stream <img> that closes the stream of every img the
+ * component drops: unmounted, re-keyed, or hidden by a conditional.
+ *
+ * A detached img keeps its MJPEG connection open (measured in Chromium) until
+ * the browser closes: the backend stream, the chamber light it holds and the
+ * /camera/stop it blocks stay with it. Removing the src closes it, also on a
+ * detached img. Done once React's commit is over and only for an img no longer
+ * in the page: React runs a ref's cleanup with the img still attached, and in
+ * development (StrictMode) also for an img that stays.
+ *
+ * Removing the src fires no event. Setting it to '' (the viewers' reconnect and
+ * restart code does, on the img still shown) fires error with an empty src:
+ * error handlers must ignore an img whose src is empty or missing.
+ *
+ * Takes the component's own ref (kept up to date with the img shown) and
+ * returns the callback to pass as the img's ref.
+ */
+export function useStreamImgRef(imgRef: MutableRefObject<HTMLImageElement | null>) {
+  return useCallback(
+    (img: HTMLImageElement | null) => {
+      imgRef.current = img;
+      if (!img) return;
+      return () => {
+        if (imgRef.current === img) imgRef.current = null;
+        queueMicrotask(() => {
+          if (!img.isConnected) img.removeAttribute('src');
+        });
+      };
+    },
+    [imgRef],
+  );
+}

+ 9 - 11
frontend/src/pages/CameraPage.tsx

@@ -10,6 +10,7 @@ import { useStreamTokenSync } from '../hooks/useCameraStreamToken';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { CameraDiagnoseModal } from '../components/CameraDiagnoseModal';
+import { useStreamImgRef } from '../hooks/useStreamImgRef';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
 const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
@@ -56,7 +57,10 @@ export function CameraPage() {
   const [panStart, setPanStart] = useState({ x: 0, y: 0 });
   const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
   const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
-  const imgRef = useRef<HTMLImageElement>(null);
+  // Every img the page drops (re-keyed by a reconnect or mode switch, left)
+  // has its stream closed.
+  const imgRef = useRef<HTMLImageElement | null>(null);
+  const attachImg = useStreamImgRef(imgRef);
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -143,16 +147,8 @@ export function CameraPage() {
 
     window.addEventListener('beforeunload', handleBeforeUnload);
 
-    // Store ref value for cleanup - ref may change by cleanup time
-    const imgElement = imgRef.current;
-
     return () => {
       window.removeEventListener('beforeunload', handleBeforeUnload);
-
-      // Clear the image source first to stop the stream
-      if (imgElement) {
-        imgElement.src = '';
-      }
       // Send stop signal only once
       sendStopOnce();
     };
@@ -329,7 +325,9 @@ export function CameraPage() {
     };
   }, [streamMode, streamLoading, streamError, isReconnecting, transitioning, id, attemptReconnect]);
 
-  const handleStreamError = () => {
+  const handleStreamError = (e: React.SyntheticEvent<HTMLImageElement>) => {
+    // Clearing the src (reconnect, restart, mode switch) fires error with an empty src.
+    if (!e.currentTarget.getAttribute('src')) return;
     setStreamLoading(false);
 
     // Only auto-reconnect for live stream mode
@@ -797,7 +795,7 @@ export function CameraPage() {
             </div>
           )}
           <img
-            ref={imgRef}
+            ref={attachImg}
             key={imageKey}
             src={currentUrl}
             alt={t('camera.cameraStream')}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AdditionalSection-D_Hs7ga0.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AmsUnitCard-BlibgKmd.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ArchivesPage-RdkckclR.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AssignToAmsModal-CkKkmT-k.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CamWallPage-MvzF5-W5.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraDiagnoseModal-B8tzQ5r7.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraPage-DvMFfXde.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraPage-UKhKNqXC.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraWall-Cx-yAK74.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraWall-D5tCGkia.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ColumnConfigModal-CHWt_9zL.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DKOr3s81.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConnectAuthorizePage-w_X_jeHo.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ContextMenu-CXDQbNZ-.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ExternalLinkPage-Dq91pEIF.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/FileManagerPage-D_LM_o4f.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/FileUploadModal-CX5sSlUQ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/FinancePage-Bobn6qEc.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/GCodeViewerPage-DjiHJW7A.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/GroupEditPage-B834Oi8w.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ImagePreviewModal-DABZ_mCG.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/InventoryPage-DcHlbZnn.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/LibraryTrashPage-DK3ahQIR.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/LoginPage-Dn1CdNkk.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/MaintenancePage-BfDONORn.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ModelSourcesPage-CVNMpxJ5.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/NotificationsPage-qQ-oBdol.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/PdfPreviewModal-Bn8pMaZA.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PhotoGalleryModal-DpNF7SvR.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrinterLocationsPage-CbreDUzS.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrintersPage-DfIFywvM.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrintersPage-XKILhdfY.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProfilesPage-CRfcGl8j.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectDetailPage-CoreoL0j.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectsPage-BqNRVw6s.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/QueuePage-QDl5KC-l.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/RichTextEditor-BvX5KokE.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SettingsPage-DrjKlvxW.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SetupPage-DJq0NUU_.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SliceModal-BUQHgTI0.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyAmsPage-COi2tLEu.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-9mn3MgNw.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyDashboard-CzZNEPLC.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-KmbI9U-e.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DY7zeBdM.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-BwnYb-lK.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpreadsheetPreviewModal-D-TUvcT6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/StatsPage-DiN6uSXT.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/StreamOverlayPage-CI1u4zAZ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SystemInfoPage-D6XFwHG8.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-B-1xu8EB.js


+ 1 - 1
static/assets/inventorySearch-C_rSVtx3.js → static/assets/inventorySearch-C5Ldfq0w.js

@@ -1 +1 @@
-import{ft as e}from"./index-hIcpEngp.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-B-1xu8EB.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/locationSensorDefaults-ByuoRaCK.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/pdf-DN0tUP9U.js


+ 1 - 0
static/assets/useStreamImgRef-CRYQNmK0.js

@@ -0,0 +1 @@
+import{i as e}from"./chunk-aKtaBQYM.js";import{D as t}from"./client-DnySrlFv.js";var n=e(t(),1);function r(e){return(0,n.useCallback)(t=>{if(e.current=t,t)return()=>{e.current===t&&(e.current=null),queueMicrotask(()=>{t.isConnected||t.removeAttribute(`src`)})}},[e])}export{r as t};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/utils-BnTZfYVQ.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-hIcpEngp.js"></script>
+    <script type="module" crossorigin src="/assets/index-B-1xu8EB.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DnySrlFv.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-Ce8_sN3w.js">

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů