Ver código fonte

feat(camera): printer cameras from any page (issue #2737)

- Embedded camera overlays are hosted by a new EmbeddedCameraProvider
  around the layout instead of the Printers page, so they stay open on
  every page; same openEmbeddedCameras key, written per change so tabs
  keep each other's entries
- Cameras button at the end of the sidebar's Printers row (a corner
  button on hover when collapsed), in the compact header, and on the C
  key lists the printers the user can see and opens or closes their
  overlay; the list is drawn at page level, takes focus and closes on
  Escape or blur
- Pop out: the overlay opens the camera window and closes, unless a
  popup blocker stopped it
- Pop back in: the camera window asks open tabs over a BroadcastChannel;
  one is claimed (the opener first), checks the printer against its
  fresh printer list or refuses, opens the overlay and the window closes
- Overlays, launcher and pop-in need camera:view and printers:read;
  stored overlays show only after a printer list fetched since mount
  and are dropped for printers the user cannot see
- Viewers open and stay below the compact header and fit a phone
- Translations for all 15 locales
maziggy 1 dia atrás
pai
commit
36d0113d21
100 arquivos alterados com 1714 adições e 72 exclusões
  1. 5 0
      CHANGELOG.md
  2. 2 1
      frontend/src/App.tsx
  3. 127 0
      frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx
  4. 97 1
      frontend/src/__tests__/components/Layout.test.tsx
  5. 441 0
      frontend/src/__tests__/contexts/EmbeddedCameraContext.test.tsx
  6. 67 0
      frontend/src/__tests__/pages/CameraPage.test.tsx
  7. 3 3
      frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx
  8. 4 1
      frontend/src/__tests__/utils.tsx
  9. 142 0
      frontend/src/__tests__/utils/cameraPopIn.test.ts
  10. 191 0
      frontend/src/components/CameraLauncher.tsx
  11. 45 15
      frontend/src/components/EmbeddedCameraViewer.tsx
  12. 1 0
      frontend/src/components/KeyboardShortcutsModal.tsx
  13. 39 2
      frontend/src/components/Layout.tsx
  14. 176 0
      frontend/src/contexts/EmbeddedCameraContext.tsx
  15. 10 0
      frontend/src/i18n/locales/de.ts
  16. 10 0
      frontend/src/i18n/locales/en.ts
  17. 10 0
      frontend/src/i18n/locales/es.ts
  18. 10 0
      frontend/src/i18n/locales/fr.ts
  19. 10 0
      frontend/src/i18n/locales/it.ts
  20. 10 0
      frontend/src/i18n/locales/ja.ts
  21. 10 0
      frontend/src/i18n/locales/ko.ts
  22. 10 0
      frontend/src/i18n/locales/nl.ts
  23. 10 0
      frontend/src/i18n/locales/pt-BR.ts
  24. 10 0
      frontend/src/i18n/locales/ru.ts
  25. 10 0
      frontend/src/i18n/locales/sv.ts
  26. 10 0
      frontend/src/i18n/locales/tr.ts
  27. 10 0
      frontend/src/i18n/locales/uk.ts
  28. 10 0
      frontend/src/i18n/locales/zh-CN.ts
  29. 10 0
      frontend/src/i18n/locales/zh-TW.ts
  30. 45 1
      frontend/src/pages/CameraPage.tsx
  31. 7 43
      frontend/src/pages/PrintersPage.tsx
  32. 169 2
      frontend/src/utils/camera.ts
  33. 0 0
      static/assets/ArchivesPage-BK9OwB_q.js
  34. 0 0
      static/assets/ArchivesPage-D2d9XGOW.js
  35. 0 0
      static/assets/AssignToAmsModal-DTLU3pN7.js
  36. 0 0
      static/assets/AuthContext-DM3YxV_q.js
  37. 0 0
      static/assets/AuthContext-XuzzSfKE.js
  38. 0 0
      static/assets/CamWallPage-CcCdDeFC.js
  39. 0 0
      static/assets/CamWallPage-Dc7zAekP.js
  40. 0 0
      static/assets/CameraDiagnoseModal-BydfnYfD.js
  41. 0 0
      static/assets/CameraDiagnoseModal-CZO_D5up.js
  42. 0 0
      static/assets/CameraPage-14haujuN.js
  43. 0 0
      static/assets/CameraPage-BJQnxW0i.js
  44. 0 0
      static/assets/CameraWall-DBrA6v1h.js
  45. 0 0
      static/assets/CameraWall-OtS7h9wP.js
  46. 0 0
      static/assets/ConfigureAmsSlotModal-CQ5519i0.js
  47. 0 0
      static/assets/ConfirmOutcomeDialog-C6HIdza4.js
  48. 0 0
      static/assets/ConnectAuthorizePage-CHor995a.js
  49. 0 0
      static/assets/EmbeddedCameraViewer-aZnEbFDI.js
  50. 0 0
      static/assets/ExternalLinkPage-Lcr-VcSz.js
  51. 0 1
      static/assets/FileManagerPage-B8h6rQf-.js
  52. 1 0
      static/assets/FileManagerPage-D-O29HVu.js
  53. 0 0
      static/assets/FinancePage-DNe-MhiO.js
  54. 0 0
      static/assets/FinancePage-bVN1geRz.js
  55. 0 0
      static/assets/InventoryPage-DED8lADO.js
  56. 0 0
      static/assets/InventoryPage-Dl4gQUjs.js
  57. 0 0
      static/assets/Layout-5XmJ6sFu.js
  58. 0 0
      static/assets/Layout-DfNbRVI6.js
  59. 0 0
      static/assets/LibraryTrashPage-zTng2FQV.js
  60. 0 0
      static/assets/LoginPage-CWtJOR7T.js
  61. 0 0
      static/assets/MaintenancePage-XeSE9RRD.js
  62. 0 0
      static/assets/ModelSourcesPage-BMef_jNQ.js
  63. 0 0
      static/assets/NotificationsPage-BUAtuAOt.js
  64. 0 0
      static/assets/PhotoGalleryModal-8Mu8hW3P.js
  65. 0 0
      static/assets/PrintModal-CZCHIZi7.js
  66. 0 0
      static/assets/PrinterLocationsPage-dQrnPQMO.js
  67. 0 0
      static/assets/PrintersPage-BMqz0Ozs.js
  68. 0 0
      static/assets/PrintersPage-D2kD7-mc.js
  69. 0 0
      static/assets/ProfilesPage-CtxTLlQr.js
  70. 0 0
      static/assets/ProjectDetailPage-B6zD7j31.js
  71. 0 0
      static/assets/ProjectDetailPage-DOLWJQsR.js
  72. 0 0
      static/assets/ProjectsPage-BxbYXMaN.js
  73. 0 0
      static/assets/QueuePage-BWrK8xh_.js
  74. 0 0
      static/assets/QueuePage-D8PxEvf8.js
  75. 0 0
      static/assets/RichTextEditor-DRq8sA5e.js
  76. 0 0
      static/assets/SettingsPage-3-NATkhb.js
  77. 0 0
      static/assets/SettingsPage-BUVSoK4T.js
  78. 0 0
      static/assets/SetupPage-CnhYrJK5.js
  79. 0 0
      static/assets/SkipObjectsModal-CXP7UaJN.js
  80. 0 0
      static/assets/SliceModal-CaTpFeKv.js
  81. 0 0
      static/assets/SpoolBuddyAmsPage-CzceN3rX.js
  82. 0 0
      static/assets/SpoolBuddyDashboard-CScPvPE5.js
  83. 0 0
      static/assets/SpoolBuddyInventoryPage-CldnCSnF.js
  84. 1 1
      static/assets/SpoolBuddySettingsPage-BzhEc5TM.js
  85. 0 0
      static/assets/SpoolBuddyWriteTagPage-C_60sPa8.js
  86. 0 0
      static/assets/StatsPage-EoL4WVGs.js
  87. 0 0
      static/assets/StreamOverlayPage-BPYmo-Mp.js
  88. 0 0
      static/assets/StreamOverlayPage-BxAJ-hoc.js
  89. 0 0
      static/assets/SystemInfoPage-_GIClDVV.js
  90. 0 0
      static/assets/ThemeContext-e7wt-avj.js
  91. 0 0
      static/assets/camera-DVxLwUaj.js
  92. 1 1
      static/assets/currency-R7oUEamO.js
  93. 0 0
      static/assets/de-BikatwOZ.js
  94. 0 0
      static/assets/de-CFWeH8Ul.js
  95. 0 0
      static/assets/es-Cchn5VB9.js
  96. 0 0
      static/assets/es-L-2yOMsc.js
  97. 0 0
      static/assets/fr-BOnzRK4I.js
  98. 0 0
      static/assets/fr-BT_J_zVM.js
  99. 0 0
      static/assets/index-DAazpymq.css
  100. 0 0
      static/assets/index-DPuWk1vL.js

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.0b1] - Unreleased
 
 ### Added
+- **Printer cameras from any page (#2737, requested by @apizz)** — An embedded camera overlay belonged to the Printers page: it disappeared on every other page and came back only on returning there, so a quick look at a print meant leaving the page you were on.
+  - **On every page:** open overlays now stay open as you move around Bambuddy, and still reopen after a reload.
+  - **Cameras button:** a camera icon at the end of the sidebar's **Printers** row (on hover when the sidebar is collapsed), in the header on narrow screens, or the **C** key, lists the printers you can see and opens one as an overlay. A camera already open is ticked and closes from the same list. Users without `camera:view` and `printers:read` don't see it, or any overlay.
+  - **Pop out and back in:** a new button in the overlay moves the camera into its own window. The camera window has a button that moves it back as an overlay into the Bambuddy tab it came from, or another open one whose user can see that printer, and closes. With no Bambuddy tab open, the window stays and says so.
+  - Overlays left open for a deleted printer, or one the signed-in user can't see, are dropped. Wiki updated.
 - **Egyptian Pound (EGP) as a currency (#3334, requested by @aammash)** — Costs can now be shown in Egyptian Pounds (E£). Pick it under Settings > General > Cost Tracking.
 - **Larger queue thumbnails on hover (#3313, requested by @humplemann)** — A job's thumbnail on a **Queue** card is the quickest way to tell jobs apart, but it is small. Hovering it now shows it at 256x256 next to the card, as the **History** rows already did at 192x192 (now 256x256 too). The preview opens to the left when there is no room on the right, is no longer cut off inside a batch group, and closes when you move away or scroll. Desktop only, as touch screens have no hover. Wiki updated.
 - **The queue says when a job printed in a colour the file didn't ask for (#3279, requested by @Lioncat55)** — With no spool of a slot's colour loaded, the queue can still send the job with another spool of the same material. Nothing showed this until the print came out.

+ 2 - 1
frontend/src/App.tsx

@@ -10,6 +10,7 @@ import { ToastProvider } from './contexts/ToastContext';
 import { SliceJobTrackerProvider } from './contexts/SliceJobTrackerContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
 import { ColorCatalogProvider } from './contexts/ColorCatalogContext';
+import { EmbeddedCameraProvider } from './contexts/EmbeddedCameraContext';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
 import { PageLoading } from './components/PageLoading';
 import { AuthRetrying } from './components/AuthRetrying';
@@ -229,7 +230,7 @@ function App() {
                 </Route>
 
                 {/* Main app with WebSocket for real-time updates */}
-                <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
+                <Route element={<ProtectedRoute><WebSocketProvider><EmbeddedCameraProvider><Layout /></EmbeddedCameraProvider></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />

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

@@ -163,3 +163,130 @@ describe('EmbeddedCameraViewer with a camera turned sideways', () => {
     expect(img.style.maxHeight).toBe('');
   });
 });
+
+describe('EmbeddedCameraViewer pop-out (#2737)', () => {
+  beforeEach(() => {
+    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('opens the camera window and closes the overlay', async () => {
+    const open = vi.spyOn(window, 'open').mockReturnValue({} as Window);
+    const onClose = vi.fn();
+    render(<EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={onClose} />);
+    await settle();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Open in its own window' }));
+
+    expect(open).toHaveBeenCalledWith('/camera/7', 'camera-7', expect.any(String));
+    expect(onClose).toHaveBeenCalledOnce();
+  });
+
+  it('keeps the overlay when a popup blocker stopped the window', async () => {
+    vi.spyOn(window, 'open').mockReturnValue(null);
+    const onClose = vi.fn();
+    render(<EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={onClose} />);
+    await settle();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Open in its own window' }));
+
+    expect(onClose).not.toHaveBeenCalled();
+  });
+});
+
+describe('EmbeddedCameraViewer first position', () => {
+  const originalWidth = window.innerWidth;
+
+  afterEach(() => {
+    Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: originalWidth });
+    vi.mocked(localStorage.getItem).mockReset();
+  });
+
+  it('moves below the header when the window narrows into the compact layout', async () => {
+    Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 });
+    server.use(http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })));
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+      key === 'embeddedCameraState_7' ? JSON.stringify({ x: 900, y: 20, width: 400, height: 300 }) : null,
+    );
+    const listeners: Array<(e: { matches: boolean }) => void> = [];
+    const originalMatchMedia = window.matchMedia;
+    window.matchMedia = ((query: string) => ({
+      ...originalMatchMedia(query),
+      matches: false,
+      addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => listeners.push(cb),
+      removeEventListener: () => {},
+    })) as typeof window.matchMedia;
+    try {
+      const { container } = render(<EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={() => {}} />);
+      await settle();
+      const box = container.querySelector('.fixed') as HTMLElement;
+      expect(box.style.top).toBe('20px');
+
+      await act(async () => listeners.forEach(cb => cb({ matches: true })));
+
+      expect(parseInt(box.style.top, 10)).toBeGreaterThanOrEqual(56);
+    } finally {
+      window.matchMedia = originalMatchMedia;
+    }
+  });
+
+  it('cannot be dragged over the compact header', async () => {
+    Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 390 });
+    server.use(http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })));
+    const originalMatchMedia = window.matchMedia;
+    window.matchMedia = ((query: string) => ({ ...originalMatchMedia(query), matches: /max-width/.test(query) })) as typeof window.matchMedia;
+    try {
+      const { container } = render(<EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={() => {}} />);
+      await settle();
+      const box = container.querySelector('.fixed') as HTMLElement;
+
+      fireEvent.mouseDown(screen.getByText('P1S'), { clientX: 100, clientY: 80 });
+      fireEvent.mouseMove(document, { clientX: 100, clientY: 0 });
+      fireEvent.mouseUp(document);
+      expect(parseInt(box.style.top, 10)).toBeGreaterThanOrEqual(56);
+
+      // And by touch, as on a phone.
+      fireEvent.touchStart(screen.getByText('P1S'), { touches: [{ clientX: 100, clientY: 80 }] });
+      fireEvent.touchMove(document, { touches: [{ clientX: 100, clientY: 0 }] });
+      fireEvent.touchEnd(document);
+      expect(parseInt(box.style.top, 10)).toBeGreaterThanOrEqual(56);
+    } finally {
+      window.matchMedia = originalMatchMedia;
+    }
+  });
+
+  it('keeps a position saved before the update below the compact header', async () => {
+    Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 390 });
+    server.use(http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })));
+    // The old default, saved by every viewer half a second after it opened.
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+      key === 'embeddedCameraState_7' ? JSON.stringify({ x: -30, y: 20, width: 400, height: 300 }) : null,
+    );
+
+    const { container } = render(<EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={() => {}} />);
+    await settle();
+
+    const box = container.querySelector('.fixed') as HTMLElement;
+    expect(parseInt(box.style.top, 10)).toBeGreaterThanOrEqual(56);
+  });
+
+  it('opens below the compact header and inside a phone-sized window (#2737)', async () => {
+    Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 390 });
+    server.use(http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })));
+
+    const { container } = render(<EmbeddedCameraViewer printerId={7} printerName="P1S" onClose={() => {}} />);
+    await settle();
+
+    const box = container.querySelector('.fixed') as HTMLElement;
+    // The 56px header holds the camera launcher and the bug report.
+    expect(parseInt(box.style.top, 10)).toBeGreaterThanOrEqual(56);
+    expect(parseInt(box.style.left, 10)).toBeGreaterThanOrEqual(0);
+    expect(parseInt(box.style.left, 10) + parseInt(box.style.width, 10)).toBeLessThanOrEqual(390);
+  });
+});

+ 97 - 1
frontend/src/__tests__/components/Layout.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
 import { render } from '../utils';
 import { Layout } from '../../components/Layout';
 import { getAuthToken, setAuthToken } from '../../api/client';
@@ -376,6 +376,102 @@ describe('Layout', () => {
     });
   });
 
+  describe('camera launcher (#2737)', () => {
+    const originalMatchMedia = window.matchMedia;
+    const originalWidth = window.innerWidth;
+    /** Below 1144px the sidebar is a drawer and the compact header shows. */
+    function compactViewport() {
+      Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 390 });
+      Object.defineProperty(window, 'matchMedia', {
+        writable: true,
+        configurable: true,
+        value: (query: string) => ({ ...originalMatchMedia(query), matches: /max-width/.test(query) }),
+      });
+    }
+
+    beforeEach(() => {
+      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(() => {
+      Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: originalMatchMedia });
+      Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: originalWidth });
+    });
+
+    it('is on the Printers row of the sidebar, not in its footer', async () => {
+      render(<Layout />);
+
+      await waitFor(() => expect(document.querySelector('header')).toBeNull());
+      const aside = document.querySelector('aside')!;
+      const button = within(aside).getByRole('button', { name: 'Cameras' });
+      expect(button.closest('li')?.querySelector('a[href="/"]')).not.toBeNull();
+      expect(within(aside).getAllByRole('button', { name: 'Cameras' })).toHaveLength(1);
+    });
+
+    it('opens the camera list with the C key', async () => {
+      render(<Layout />);
+      await waitFor(() => expect(document.querySelector('aside')).not.toBeNull());
+
+      fireEvent.keyDown(document, { key: 'c' });
+      expect(await screen.findByRole('button', { name: /X1 Carbon/ })).toBeInTheDocument();
+
+      fireEvent.keyDown(document, { key: 'c' });
+      await waitFor(() => expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull());
+    });
+
+    it('leaves the C key alone while typing', async () => {
+      render(
+        <>
+          <input aria-label="search" />
+          <Layout />
+        </>,
+      );
+      await waitFor(() => expect(document.querySelector('aside')).not.toBeNull());
+
+      fireEvent.keyDown(screen.getByRole('textbox', { name: 'search' }), { key: 'c' });
+
+      expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull();
+    });
+
+    it('opens the header\'s list with the C key in the compact layout', async () => {
+      compactViewport();
+      render(<Layout />);
+      await waitFor(() => expect(document.querySelector('header')).not.toBeNull());
+
+      fireEvent.keyDown(document, { key: 'c' });
+
+      expect(await screen.findByRole('region', { name: 'Cameras' })).toBeInTheDocument();
+      // One list, the header's, not also the one in the closed drawer.
+      expect(screen.getAllByRole('region', { name: 'Cameras' })).toHaveLength(1);
+      expect(within(document.querySelector('header')!).getByRole('button', { name: 'Cameras' })).toHaveAttribute(
+        'aria-expanded',
+        'true',
+      );
+    });
+
+    it('is in the compact header, beside the bug report', async () => {
+      compactViewport();
+      render(<Layout />);
+
+      await waitFor(() => expect(document.querySelector('header')).not.toBeNull());
+      expect(within(document.querySelector('header')!).getByRole('button', { name: 'Cameras' })).toBeInTheDocument();
+    });
+
+    it('opens a camera overlay on a page other than Printers', async () => {
+      compactViewport();
+      render(<Layout />);
+      await waitFor(() => expect(document.querySelector('header')).not.toBeNull());
+
+      fireEvent.click(within(document.querySelector('header')!).getByRole('button', { name: 'Cameras' }));
+      fireEvent.click(await screen.findByRole('button', { name: /X1 Carbon/ }));
+
+      expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument();
+    });
+  });
+
   describe('theme toggle', () => {
     it('has theme toggle button', async () => {
       render(<Layout />);

+ 441 - 0
frontend/src/__tests__/contexts/EmbeddedCameraContext.test.tsx

@@ -0,0 +1,441 @@
+/**
+ * Camera overlays from any page (#2737). They used to live in the Printers
+ * page, so they disappeared on every other page.
+ */
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
+import { AuthProvider } from '../../contexts/AuthContext';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { EmbeddedCameraProvider } from '../../contexts/EmbeddedCameraContext';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { CameraLauncher } from '../../components/CameraLauncher';
+import { setAuthToken } from '../../api/client';
+import { requestCameraPopIn } from '../../utils/camera';
+
+const PRINTERS = [
+  { id: 1, name: 'Workshop X1C' },
+  { id: 2, name: 'Office P1S' },
+];
+
+// The setup file stubs localStorage out; this backs it so what the host keeps
+// there can be read back.
+const storage = new Map<string, string>();
+function backStorage() {
+  storage.clear();
+  vi.mocked(localStorage.getItem).mockImplementation((key: string) => storage.get(key) ?? null);
+  vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
+    storage.set(key, value);
+  });
+  vi.mocked(localStorage.removeItem).mockImplementation((key: string) => {
+    storage.delete(key);
+  });
+}
+
+function SomeOtherPage() {
+  return <h1>Archives</h1>;
+}
+
+function overlayCount() {
+  // "Refresh stream" is the overlay's own control.
+  return screen.queryAllByTitle('Refresh stream').length;
+}
+
+// The host loads the viewer on first use; a cold import under a busy test run
+// can outlast waitFor's second.
+beforeAll(async () => {
+  await import('../../components/EmbeddedCameraViewer');
+});
+
+describe('EmbeddedCameraProvider', () => {
+  beforeEach(() => {
+    backStorage();
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
+      http.get('/api/v1/printers/:id', ({ params }) =>
+        HttpResponse.json(PRINTERS.find(p => p.id === Number(params.id)) ?? null, {
+          status: PRINTERS.some(p => p.id === Number(params.id)) ? 200 : 404,
+        }),
+      ),
+      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();
+    sessionStorage.clear();
+  });
+
+  it('shows an overlay left open on any page, not only on Printers', async () => {
+    localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+
+    render(<SomeOtherPage />);
+
+    await waitFor(() => expect(overlayCount()).toBe(1));
+    expect(screen.getByText('Archives')).toBeInTheDocument();
+  });
+
+  it('drops an overlay for a printer the user cannot see', async () => {
+    localStorage.setItem(
+      'openEmbeddedCameras',
+      JSON.stringify([{ id: 2, name: 'Office P1S' }, { id: 99, name: 'Deleted' }]),
+    );
+
+    render(<SomeOtherPage />);
+
+    await waitFor(() =>
+      expect(JSON.parse(localStorage.getItem('openEmbeddedCameras')!)).toEqual([{ id: 2, name: 'Office P1S' }]),
+    );
+    await waitFor(() => expect(overlayCount()).toBe(1));
+  });
+
+  it('starts with none open from a corrupt entry', async () => {
+    localStorage.setItem('openEmbeddedCameras', '{not json');
+
+    render(<SomeOtherPage />);
+
+    expect(await screen.findByText('Archives')).toBeInTheDocument();
+    expect(overlayCount()).toBe(0);
+  });
+
+  it('skips a stored entry without a valid printer id', async () => {
+    // The printer list is not available, so nothing is pruned against it.
+    server.use(http.get('/api/v1/printers/', () => new HttpResponse(null, { status: 500 })));
+    localStorage.setItem(
+      'openEmbeddedCameras',
+      JSON.stringify([{ name: 'no id' }, { id: '2', name: 'string id' }, { id: 0 }, null, { id: 2, name: 'Office P1S' }]),
+    );
+
+    render(<SomeOtherPage />);
+
+    await waitFor(() => expect(overlayCount()).toBe(1));
+    await new Promise(resolve => setTimeout(resolve, 50));
+    expect(overlayCount()).toBe(1);
+  });
+
+  it('opens an overlay a camera window pops back in', async () => {
+    render(<SomeOtherPage />);
+    await screen.findByText('Archives');
+
+    await waitFor(async () =>
+      expect(await requestCameraPopIn(1, 'Workshop X1C', null, { answerMs: 50 })).toBe(true),
+    );
+
+    await waitFor(() => expect(overlayCount()).toBe(1));
+  });
+
+  it('shows a stored overlay only once the printer list is in', async () => {
+    let release!: () => void;
+    const gate = new Promise<void>(resolve => { release = resolve; });
+    server.use(http.get('/api/v1/printers/', async () => { await gate; return HttpResponse.json(PRINTERS); }));
+    localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+
+    render(<SomeOtherPage />);
+    await screen.findByText('Archives');
+    await new Promise(resolve => setTimeout(resolve, 50));
+    // The cache may still hold the last user's list; nothing streams before
+    // this user's has been checked.
+    expect(overlayCount()).toBe(0);
+
+    release();
+    await waitFor(() => expect(overlayCount()).toBe(1));
+  });
+
+  it('refuses a pop-in for a printer the user cannot see, so the window keeps it', async () => {
+    localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+    render(<SomeOtherPage />);
+    await waitFor(() => expect(overlayCount()).toBe(1));
+
+    await expect(requestCameraPopIn(99, 'Not yours', null, { answerMs: 50 })).resolves.toBe(false);
+
+    expect(JSON.parse(localStorage.getItem('openEmbeddedCameras')!)).toEqual([{ id: 2, name: 'Office P1S' }]);
+    expect(overlayCount()).toBe(1);
+  });
+
+  it('checks a pop-in against the list as it is now, not the cached one', async () => {
+    // An open overlay has the list fetched and cached.
+    localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+    render(<SomeOtherPage />);
+    await waitFor(() => expect(overlayCount()).toBe(1));
+    // Printer 3 was added in another tab since this one last fetched.
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([...PRINTERS, { id: 3, name: 'New A1' }])),
+      http.get('/api/v1/printers/:id', ({ params }) => HttpResponse.json({ id: Number(params.id), name: 'New A1' })),
+    );
+
+    await expect(requestCameraPopIn(3, 'New A1', null, { answerMs: 50 })).resolves.toBe(true);
+
+    await waitFor(() => expect(overlayCount()).toBe(2));
+  });
+
+  describe('remounted with the printer list still cached', () => {
+    // Back from /camwall, or signed in again without a reload: the provider
+    // mounts anew while the cache holds a list under a minute old.
+    function renderWithCache(cached: Array<{ id: number; name: string }>) {
+      const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 60_000 } } });
+      queryClient.setQueryData(['printers'], cached);
+      return rtlRender(
+        <QueryClientProvider client={queryClient}>
+          <BrowserRouter>
+            <AuthProvider>
+              <ThemeProvider>
+                <ToastProvider>
+                  <EmbeddedCameraProvider>
+                    <SomeOtherPage />
+                  </EmbeddedCameraProvider>
+                </ToastProvider>
+              </ThemeProvider>
+            </AuthProvider>
+          </BrowserRouter>
+        </QueryClientProvider>,
+      );
+    }
+
+    it('still shows the stored overlay', async () => {
+      localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+
+      renderWithCache(PRINTERS);
+
+      await waitFor(() => expect(overlayCount()).toBe(1));
+    });
+
+    it('checks a pop-in against the server, not the minute-old list', async () => {
+      localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+      renderWithCache(PRINTERS);
+      await waitFor(() => expect(overlayCount()).toBe(1));
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([...PRINTERS, { id: 3, name: 'New A1' }])),
+        http.get('/api/v1/printers/:id', ({ params }) => HttpResponse.json({ id: Number(params.id), name: 'New A1' })),
+      );
+
+      await expect(requestCameraPopIn(3, 'New A1', null, { answerMs: 50 })).resolves.toBe(true);
+    });
+
+    it('goes by the list fetched now, not the last user\'s cached one', async () => {
+      localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+      let release!: () => void;
+      const gate = new Promise<void>(resolve => { release = resolve; });
+      server.use(http.get('/api/v1/printers/', async () => { await gate; return HttpResponse.json([PRINTERS[0]]); }));
+
+      renderWithCache(PRINTERS);
+      await screen.findByText('Archives');
+      await new Promise(resolve => setTimeout(resolve, 50));
+      // The cached list has printer 2; this user's, still on its way, has not.
+      expect(overlayCount()).toBe(0);
+
+      release();
+      await waitFor(() => expect(localStorage.getItem('openEmbeddedCameras')).toBeNull());
+      expect(overlayCount()).toBe(0);
+    });
+  });
+
+  it('keeps what other tabs stored when this one opens and closes a camera', async () => {
+    render(
+      <>
+        <SomeOtherPage />
+        <CameraLauncher placement="side" />
+      </>,
+    );
+    await screen.findByText('Archives');
+    // Another tab opened camera 1 after this one loaded.
+    localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 1, name: 'Workshop X1C' }]));
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+    fireEvent.click(await screen.findByRole('button', { name: /Office P1S/ }));
+    await waitFor(() =>
+      expect(JSON.parse(localStorage.getItem('openEmbeddedCameras')!)).toEqual([
+        { id: 1, name: 'Workshop X1C' },
+        { id: 2, name: 'Office P1S' },
+      ]),
+    );
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+    fireEvent.click(await screen.findByRole('button', { name: /Office P1S/ }));
+    await waitFor(() =>
+      expect(JSON.parse(localStorage.getItem('openEmbeddedCameras')!)).toEqual([{ id: 1, name: 'Workshop X1C' }]),
+    );
+  });
+
+  describe.each([
+    ['camera:view', ['printers:read']],
+    ['printers:read', ['camera:view']],
+  ])('without %s', (_missing, permissions) => {
+    beforeEach(() => {
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 2,
+            username: 'viewer',
+            role: 'user',
+            is_active: true,
+            is_admin: false,
+            groups: [],
+            permissions,
+            created_at: '2026-01-01T00:00:00Z',
+          }),
+        ),
+      );
+      setAuthToken('test-token', 'session');
+    });
+
+    it('shows no overlay and no launcher', async () => {
+      localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+
+      render(
+        <>
+          <SomeOtherPage />
+          <CameraLauncher placement="side" />
+        </>,
+      );
+
+      await screen.findByText('Archives');
+      // Give the permissions time to arrive, then nothing camera-related is up.
+      await new Promise(resolve => setTimeout(resolve, 100));
+      expect(overlayCount()).toBe(0);
+      expect(screen.queryByRole('button', { name: 'Cameras' })).toBeNull();
+    });
+
+    it('does not take a camera window popping in', async () => {
+      render(<SomeOtherPage />);
+      await screen.findByText('Archives');
+      await new Promise(resolve => setTimeout(resolve, 100));
+
+      await expect(requestCameraPopIn(1, 'Workshop X1C', null, { answerMs: 50 })).resolves.toBe(false);
+    });
+  });
+});
+
+describe('CameraLauncher', () => {
+  beforeEach(() => {
+    backStorage();
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
+      http.get('/api/v1/printers/:id', ({ params }) =>
+        HttpResponse.json(PRINTERS.find(p => p.id === Number(params.id))),
+      ),
+      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('lists the printers and opens the picked one as an overlay', async () => {
+    render(<CameraLauncher placement="side" />);
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+    fireEvent.click(await screen.findByRole('button', { name: /Office P1S/ }));
+
+    await waitFor(() => expect(overlayCount()).toBe(1));
+    // A quick look: the list gets out of the way.
+    expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull();
+    expect(JSON.parse(localStorage.getItem('openEmbeddedCameras')!)).toEqual([{ id: 2, name: 'Office P1S' }]);
+  });
+
+  it('marks an open camera and closes it from the list', async () => {
+    localStorage.setItem('openEmbeddedCameras', JSON.stringify([{ id: 2, name: 'Office P1S' }]));
+    render(<CameraLauncher placement="down" />);
+    await waitFor(() => expect(overlayCount()).toBe(1));
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+    const item = await screen.findByRole('button', { name: /Office P1S/ });
+    expect(item).toHaveAttribute('aria-pressed', 'true');
+    expect(screen.getByRole('button', { name: /Workshop X1C/ })).toHaveAttribute('aria-pressed', 'false');
+
+    fireEvent.click(item);
+
+    await waitFor(() => expect(overlayCount()).toBe(0));
+    expect(localStorage.getItem('openEmbeddedCameras')).toBeNull();
+  });
+
+  it('closes the list on Escape and on a click elsewhere', async () => {
+    render(
+      <>
+        <p>outside</p>
+        <CameraLauncher placement="side" />
+      </>,
+    );
+    const button = await screen.findByRole('button', { name: 'Cameras' });
+
+    fireEvent.click(button);
+    expect(await screen.findByRole('region', { name: 'Cameras' })).toBeInTheDocument();
+    fireEvent.keyDown(document, { key: 'Escape' });
+    expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull();
+
+    fireEvent.click(button);
+    expect(await screen.findByRole('region', { name: 'Cameras' })).toBeInTheDocument();
+    fireEvent.pointerDown(screen.getByText('outside'));
+    expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull();
+  });
+
+  it('draws the list at page level, above the overlays', async () => {
+    render(
+      <aside className="fixed z-30">
+        <CameraLauncher placement="side" />
+      </aside>,
+    );
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+
+    const list = await screen.findByRole('region', { name: 'Cameras' });
+    // Inside the sidebar it would be capped at the sidebar's z-30 and clipped
+    // by its scrolling footer.
+    expect(list.closest('aside')).toBeNull();
+    expect(list.className).toContain('fixed');
+    expect(list.className).toContain('z-50');
+  });
+
+  it('stays open for a press inside the list, which is outside the button', async () => {
+    render(<CameraLauncher placement="side" />);
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+
+    const item = await screen.findByRole('button', { name: /Office P1S/ });
+    fireEvent.pointerDown(item);
+
+    expect(screen.getByRole('region', { name: 'Cameras' })).toBeInTheDocument();
+  });
+
+  it('takes keyboard focus into the list and back to the button on Escape', async () => {
+    render(<CameraLauncher placement="side" />);
+    const button = await screen.findByRole('button', { name: 'Cameras' });
+
+    fireEvent.click(button);
+
+    // The list is drawn at the end of the page, out of the button's Tab order.
+    await waitFor(() => expect(screen.getByRole('button', { name: /Workshop X1C/ })).toHaveFocus());
+    fireEvent.keyDown(document, { key: 'Escape' });
+    expect(button).toHaveFocus();
+  });
+
+  it('closes when focus leaves the list', async () => {
+    render(
+      <>
+        <button>elsewhere</button>
+        <CameraLauncher placement="side" />
+      </>,
+    );
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+    const item = await screen.findByRole('button', { name: /Workshop X1C/ });
+
+    fireEvent.blur(item, { relatedTarget: screen.getByRole('button', { name: 'elsewhere' }) });
+
+    expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull();
+  });
+
+  it('says so when there are no printers', async () => {
+    server.use(http.get('/api/v1/printers/', () => HttpResponse.json([])));
+    render(<CameraLauncher placement="side" />);
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Cameras' }));
+
+    expect(await screen.findByText('No printers')).toBeInTheDocument();
+  });
+});

+ 67 - 0
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -15,6 +15,7 @@ import { ToastProvider } from '../../contexts/ToastContext';
 import { AuthProvider } from '../../contexts/AuthContext';
 import { I18nextProvider } from 'react-i18next';
 import i18n from '../../i18n';
+import { listenForCameraPopIn } from '../../utils/camera';
 
 // Mock navigator.sendBeacon which isn't available in jsdom
 vi.stubGlobal('navigator', {
@@ -448,6 +449,72 @@ describe('CameraPage', () => {
     });
   });
 
+  describe('back into a Bambuddy tab (#2737)', () => {
+    afterEach(() => {
+      vi.restoreAllMocks();
+      sessionStorage.clear();
+    });
+
+    it('moves into the open tab as an overlay and closes the window', async () => {
+      const open = vi.fn();
+      const stop = listenForCameraPopIn('t1', open);
+      const close = vi.spyOn(window, 'close').mockImplementation(() => {});
+      const fetchSpy = vi.spyOn(global, 'fetch');
+      try {
+        renderCameraPage(1);
+        await screen.findByText('X1 Carbon');
+
+        fireEvent.click(screen.getByRole('button', { name: 'Show as an overlay in Bambuddy' }));
+
+        await waitFor(() => expect(close).toHaveBeenCalled());
+        expect(open).toHaveBeenCalledExactlyOnceWith(1, 'X1 Carbon');
+        // A tab the script cannot close says where the camera went, and its
+        // stream is gone with the img.
+        expect(await screen.findByText(/This camera is now shown in Bambuddy/)).toBeInTheDocument();
+        expect(document.querySelector('img')).toBeNull();
+        // The view is gone with its timers, and the backend is told this
+        // viewer left even though the tab itself stays.
+        await waitFor(() =>
+          expect(fetchSpy.mock.calls.some(([url]) => String(url).includes('/printers/1/camera/stop'))).toBe(true),
+        );
+      } finally {
+        stop();
+      }
+    });
+
+    it('stays open and says so when no Bambuddy tab is open', async () => {
+      const close = vi.spyOn(window, 'close').mockImplementation(() => {});
+      renderCameraPage(1);
+      await screen.findByText('X1 Carbon');
+
+      fireEvent.click(screen.getByRole('button', { name: 'Show as an overlay in Bambuddy' }));
+
+      expect(await screen.findByText('No Bambuddy tab is open to show the camera in.')).toBeInTheDocument();
+      expect(close).not.toHaveBeenCalled();
+      expect(document.querySelector('img')).not.toBeNull();
+    });
+
+    it('goes back to the tab it was opened from', async () => {
+      // The popup's sessionStorage is a copy of its opener's.
+      sessionStorage.setItem('bambuddyTabId', 'opener');
+      const other = vi.fn();
+      const opener = vi.fn();
+      const stops = [listenForCameraPopIn('other', other), listenForCameraPopIn('opener', opener)];
+      vi.spyOn(window, 'close').mockImplementation(() => {});
+      try {
+        renderCameraPage(1);
+        await screen.findByText('X1 Carbon');
+
+        fireEvent.click(screen.getByRole('button', { name: 'Show as an overlay in Bambuddy' }));
+
+        await waitFor(() => expect(opener).toHaveBeenCalledExactlyOnceWith(1, 'X1 Carbon'));
+        expect(other).not.toHaveBeenCalled();
+      } finally {
+        stops.forEach(s => s());
+      }
+    });
+  });
+
   describe('invalid printer', () => {
     it('shows invalid printer message for ID 0', async () => {
       renderCameraPage(0);

+ 3 - 3
frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx

@@ -15,7 +15,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
-const permissions = { granted: ['camera:view', 'settings:update'] as string[] };
+const permissions = { granted: ['printers:read', 'camera:view', 'settings:update'] as string[] };
 
 const mockUseAuth = {
   user: { id: 1, username: 'operator', permissions: [] as string[] },
@@ -119,7 +119,7 @@ const storage = new Map<string, string>();
 describe('PrintersPage — camera split button', () => {
   beforeEach(() => {
     settingsWrites.length = 0;
-    permissions.granted = ['camera:view', 'settings:update'];
+    permissions.granted = ['printers:read', 'camera:view', 'settings:update'];
     storage.clear();
     vi.mocked(localStorage.getItem).mockImplementation((key: string) => storage.get(key) ?? null);
     vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
@@ -212,7 +212,7 @@ describe('PrintersPage — camera split button', () => {
   it('still applies the pick for a user who cannot write settings', async () => {
     // A viewer has camera:view but not settings:update. Their choice has to
     // stick locally, or the menu would appear to do nothing on the next click.
-    permissions.granted = ['camera:view'];
+    permissions.granted = ['printers:read', 'camera:view'];
     renderPage('window');
     await openModeMenu();
     fireEvent.click(await screen.findByText(/Embedded Overlay/));

+ 4 - 1
frontend/src/__tests__/utils.tsx

@@ -10,6 +10,7 @@ import { BrowserRouter } from 'react-router-dom';
 import { ThemeProvider } from '../contexts/ThemeContext';
 import { ToastProvider } from '../contexts/ToastContext';
 import { AuthProvider } from '../contexts/AuthContext';
+import { EmbeddedCameraProvider } from '../contexts/EmbeddedCameraContext';
 
 // Create a new QueryClient for each test
 function createTestQueryClient() {
@@ -43,7 +44,9 @@ function AllProviders({ children }: AllProvidersProps) {
             AuthProvider". */}
         <AuthProvider>
           <ThemeProvider>
-            <ToastProvider>{children}</ToastProvider>
+            <ToastProvider>
+              <EmbeddedCameraProvider>{children}</EmbeddedCameraProvider>
+            </ToastProvider>
           </ThemeProvider>
         </AuthProvider>
       </BrowserRouter>

+ 142 - 0
frontend/src/__tests__/utils/cameraPopIn.test.ts

@@ -0,0 +1,142 @@
+/**
+ * A camera window moved back into a Bambuddy tab as an overlay (#2737): one
+ * tab takes it, the one it was opened from first, and with none the window
+ * is told so it can stay open.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { listenForCameraPopIn, readOpenerTabId, registerCameraTab, requestCameraPopIn } from '../../utils/camera';
+
+const cleanups: Array<() => void> = [];
+
+function tab(tabId: string, canShow: (printerId: number) => Promise<boolean> = async () => true) {
+  const open = vi.fn();
+  cleanups.push(listenForCameraPopIn(tabId, open, canShow));
+  return open;
+}
+
+afterEach(() => {
+  cleanups.splice(0).forEach(stop => stop());
+  sessionStorage.clear();
+});
+
+describe('requestCameraPopIn', () => {
+  it('opens the camera in the tab that answers', async () => {
+    const open = tab('t1');
+
+    await expect(requestCameraPopIn(7, 'P1S', null, { answerMs: 50 })).resolves.toBe(true);
+
+    expect(open).toHaveBeenCalledExactlyOnceWith(7, 'P1S');
+  });
+
+  it('reports no tab when none is open, so the window stays', async () => {
+    await expect(requestCameraPopIn(7, 'P1S', null, { answerMs: 50 })).resolves.toBe(false);
+  });
+
+  it('opens it in one tab only when several are open', async () => {
+    const tabs = [tab('t1'), tab('t2'), tab('t3')];
+
+    await expect(requestCameraPopIn(7, 'P1S', null, { answerMs: 50 })).resolves.toBe(true);
+
+    expect(tabs.map(open => open.mock.calls.length).reduce((a, b) => a + b)).toBe(1);
+  });
+
+  it('prefers the tab the window was opened from', async () => {
+    const first = tab('t1');
+    const opener = tab('t2');
+
+    await expect(requestCameraPopIn(7, 'P1S', 't2', { answerMs: 50 })).resolves.toBe(true);
+
+    expect(opener).toHaveBeenCalledExactlyOnceWith(7, 'P1S');
+    expect(first).not.toHaveBeenCalled();
+  });
+
+  it('falls back to another tab when the one it came from is gone', async () => {
+    const other = tab('t1');
+
+    await expect(requestCameraPopIn(7, 'P1S', 'gone', { answerMs: 50 })).resolves.toBe(true);
+
+    expect(other).toHaveBeenCalledExactlyOnceWith(7, 'P1S');
+  });
+
+  it('stops answering once the tab is cleaned up', async () => {
+    const open = vi.fn();
+    listenForCameraPopIn('t1', open)();
+
+    await expect(requestCameraPopIn(7, 'P1S', null, { answerMs: 50 })).resolves.toBe(false);
+    expect(open).not.toHaveBeenCalled();
+  });
+
+  it('opens it once when a duplicated tab answers under the same tab id', async () => {
+    const original = tab('t1');
+    const duplicate = tab('t1');
+
+    await expect(requestCameraPopIn(7, 'P1S', 't1', { answerMs: 50 })).resolves.toBe(true);
+
+    expect(original.mock.calls.length + duplicate.mock.calls.length).toBe(1);
+  });
+
+  it('is not opened by a tab that gets the claim after the window gave up', async () => {
+    const open = tab('t1');
+    // The window sets the deadline at 1000; the tab reads the clock long after.
+    vi.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValue(10_000_000);
+
+    await expect(requestCameraPopIn(7, 'P1S', null, { answerMs: 50, doneMs: 200 })).resolves.toBe(false);
+
+    expect(open).not.toHaveBeenCalled();
+    vi.restoreAllMocks();
+  });
+
+  it('moves on to another tab when the claimed one cannot show the printer', async () => {
+    // Each tab has its own sign-in: the window's own tab may now be someone
+    // who cannot see this printer.
+    const refusing = tab('opener', async () => false);
+    const other = tab('t2');
+
+    await expect(requestCameraPopIn(7, 'P1S', 'opener', { answerMs: 50 })).resolves.toBe(true);
+
+    expect(refusing).not.toHaveBeenCalled();
+    expect(other).toHaveBeenCalledExactlyOnceWith(7, 'P1S');
+  });
+
+  it('stays in the window when no tab can show the printer', async () => {
+    const refusing = tab('t1', async () => false);
+
+    await expect(requestCameraPopIn(7, 'P1S', null, { answerMs: 50 })).resolves.toBe(false);
+
+    expect(refusing).not.toHaveBeenCalled();
+  });
+
+  it('treats a failed check as cannot show', async () => {
+    const failing = tab('t1', async () => {
+      throw new Error('offline');
+    });
+
+    await expect(requestCameraPopIn(7, 'P1S', null, { answerMs: 50 })).resolves.toBe(false);
+    expect(failing).not.toHaveBeenCalled();
+  });
+
+  it('ignores an offer for no valid printer', async () => {
+    const open = tab('t1');
+
+    await expect(requestCameraPopIn(0, 'P1S', null, { answerMs: 50 })).resolves.toBe(false);
+    expect(open).not.toHaveBeenCalled();
+  });
+});
+
+describe('the opener tab id', () => {
+  it('is what a window opened from the tab reads back', () => {
+    // A popup starts with a copy of its opener's sessionStorage.
+    const tabId = registerCameraTab();
+    expect(readOpenerTabId()).toBe(tabId);
+  });
+
+  it('stays the same across a reload, so an open window still finds its tab', () => {
+    expect(registerCameraTab()).toBe(registerCameraTab());
+  });
+
+  it('is made fresh in a tab that has none', () => {
+    const first = registerCameraTab();
+    sessionStorage.clear();
+    expect(registerCameraTab()).not.toBe(first);
+  });
+});

+ 191 - 0
frontend/src/components/CameraLauncher.tsx

@@ -0,0 +1,191 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent } from 'react';
+import { createPortal } from 'react-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Camera, Check, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useEmbeddedCameras } from '../contexts/EmbeddedCameraContext';
+
+interface CameraLauncherProps {
+  /** Where the printer list opens: beside the Printers row of the sidebar, or down from the compact header. */
+  placement: 'side' | 'down';
+  /** Smaller on the sidebar's Printers row (xs in the collapsed sidebar); on that row's green when it is the current page. */
+  size?: 'md' | 'sm' | 'xs';
+  onAccent?: boolean;
+  /** Controlled by the layout's keyboard shortcut when given. */
+  open?: boolean;
+  onOpenChange?: (open: boolean) => void;
+  className?: string;
+}
+
+const MENU_WIDTH = 256;
+const EDGE = 8;
+
+/**
+ * Where the list goes, in viewport coordinates. It is rendered into
+ * document.body: inside the sidebar (z-30) or the compact header (z-40) it
+ * would sit under the camera overlays (z-40, later in the page), and the
+ * collapsed sidebar's scrolling footer would clip it to 48px.
+ */
+function menuPosition(trigger: DOMRect, placement: 'side' | 'down'): CSSProperties {
+  const maxLeft = Math.max(EDGE, window.innerWidth - MENU_WIDTH - EDGE);
+  if (placement === 'down') {
+    return { top: trigger.bottom + EDGE, left: Math.min(Math.max(EDGE, trigger.right - MENU_WIDTH), maxLeft) };
+  }
+  // Out to the right of the sidebar, level with the row.
+  return { top: Math.max(EDGE, trigger.top - EDGE), left: Math.min(trigger.right + EDGE, maxLeft) };
+}
+
+/**
+ * A printer's camera as an overlay from any page (#2737), without going back
+ * to the Printers page for its card. Lists the printers the user can see; one
+ * already open closes again from here.
+ */
+export function CameraLauncher({
+  placement,
+  size = 'md',
+  onAccent = false,
+  open: openProp,
+  onOpenChange,
+  className = '',
+}: CameraLauncherProps) {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const { openCameras, openEmbeddedCamera, closeEmbeddedCamera } = useEmbeddedCameras();
+  const [ownOpen, setOwnOpen] = useState(false);
+  const open = openProp ?? ownOpen;
+  const setOpen = useCallback(
+    (next: boolean) => {
+      if (openProp === undefined) setOwnOpen(next);
+      onOpenChange?.(next);
+    },
+    [openProp, onOpenChange],
+  );
+  const [position, setPosition] = useState<CSSProperties>({});
+  const rootRef = useRef<HTMLDivElement>(null);
+  const buttonRef = useRef<HTMLButtonElement>(null);
+  const menuRef = useRef<HTMLDivElement>(null);
+  // As the overlays: the printer list takes printers:read.
+  const canView = hasPermission('camera:view') && hasPermission('printers:read');
+
+  const { data: printers, isLoading } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+    enabled: open && canView,
+  });
+
+  useLayoutEffect(() => {
+    if (!open) return;
+    const place = () => {
+      if (buttonRef.current) setPosition(menuPosition(buttonRef.current.getBoundingClientRect(), placement));
+    };
+    place();
+    window.addEventListener('resize', place);
+    return () => window.removeEventListener('resize', place);
+  }, [open, placement]);
+
+  useEffect(() => {
+    if (!open) return;
+    const onPointerDown = (e: PointerEvent) => {
+      const target = e.target as Node;
+      if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
+    };
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        setOpen(false);
+        buttonRef.current?.focus();
+      }
+    };
+    document.addEventListener('pointerdown', onPointerDown);
+    document.addEventListener('keydown', onKeyDown);
+    return () => {
+      document.removeEventListener('pointerdown', onPointerDown);
+      document.removeEventListener('keydown', onKeyDown);
+    };
+  }, [open, setOpen]);
+
+  // The list is drawn at the end of the page, so Tab from the button would
+  // not reach it: focus moves into it, and leaving it closes it.
+  const hasItems = !!printers && printers.length > 0;
+  useEffect(() => {
+    if (open && hasItems) menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
+  }, [open, hasItems]);
+  const onMenuBlur = (e: FocusEvent<HTMLDivElement>) => {
+    const next = e.relatedTarget as Node | null;
+    if (next && !menuRef.current?.contains(next) && !rootRef.current?.contains(next)) setOpen(false);
+  };
+
+  if (!canView) return null;
+
+  const openIds = new Set(openCameras.map(c => c.id));
+
+  return (
+    <div ref={rootRef} className="relative">
+      <button
+        ref={buttonRef}
+        onClick={() => setOpen(!open)}
+        className={`${{ md: 'p-2 rounded-lg', sm: 'p-1 rounded-md', xs: 'p-0.5 rounded' }[size]} transition-colors ${
+          onAccent
+            ? 'text-white hover:bg-white/20'
+            : `hover:bg-bambu-dark-tertiary ${open || openIds.size > 0 ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'}`
+        } ${className}`}
+        title={t('cameraLauncher.title')}
+        aria-label={t('cameraLauncher.title')}
+        aria-expanded={open}
+      >
+        <Camera className={{ md: 'w-5 h-5', sm: 'w-4 h-4', xs: 'w-3 h-3' }[size]} />
+      </button>
+      {open && createPortal(
+        <div
+          ref={menuRef}
+          onBlur={onMenuBlur}
+          role="region"
+          aria-label={t('cameraLauncher.title')}
+          style={position}
+          className="fixed w-64 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-xl z-50"
+        >
+          <div className="px-4 py-3 border-b border-bambu-dark-tertiary">
+            <h3 className="text-sm font-semibold text-white flex items-center gap-2">
+              <Camera className="w-4 h-4 text-bambu-green" />
+              {t('cameraLauncher.title')}
+            </h3>
+          </div>
+          <div className="p-2 max-h-80 overflow-y-auto">
+            {isLoading ? (
+              <div className="flex items-center justify-center py-6">
+                <Loader2 className="w-5 h-5 text-bambu-gray animate-spin" />
+              </div>
+            ) : !printers || printers.length === 0 ? (
+              <p className="text-sm text-bambu-gray text-center py-4 px-2">{t('cameraLauncher.noPrinters')}</p>
+            ) : (
+              printers.map(printer => {
+                const isOpen = openIds.has(printer.id);
+                return (
+                  <button
+                    key={printer.id}
+                    aria-pressed={isOpen}
+                    onClick={() => {
+                      if (isOpen) {
+                        closeEmbeddedCamera(printer.id);
+                      } else {
+                        openEmbeddedCamera(printer.id, printer.name);
+                        setOpen(false);
+                      }
+                    }}
+                    className="w-full flex items-center justify-between gap-2 py-2 px-3 text-left text-sm text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+                    title={isOpen ? t('cameraLauncher.close') : t('cameraLauncher.open')}
+                  >
+                    <span className="truncate">{printer.name}</span>
+                    {isOpen && <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />}
+                  </button>
+                );
+              })
+            )}
+          </div>
+        </div>,
+        document.body,
+      )}
+    </div>
+  );
+}

+ 45 - 15
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -1,7 +1,7 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize, Stethoscope } from 'lucide-react';
+import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize, Stethoscope, ExternalLink } from 'lucide-react';
 import { api, getAuthToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -11,8 +11,10 @@ import { CameraDiagnoseModal } from './CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
 import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
+import { openCameraWindow } from '../utils/camera';
+import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 
-interface EmbeddedCameraViewerProps {
+export interface EmbeddedCameraViewerProps {
   printerId: number;
   printerName: string;
   viewerIndex?: number;  // Used to offset multiple viewers
@@ -32,12 +34,21 @@ interface CameraState {
   height: number;
 }
 
-const DEFAULT_STATE: CameraState = {
-  x: window.innerWidth - 420,
-  y: 20,
-  width: 400,
-  height: 300,
-};
+// The compact header (56px) holds the camera launcher and the bug report
+// (#2737); a viewer, on every page now, stays below it there.
+const COMPACT_HEADER_HEIGHT = 56;
+
+// Below that header and within the window on a phone; worked out when a
+// viewer opens, not at load.
+function defaultState(): CameraState {
+  const width = Math.min(400, Math.max(200, window.innerWidth - 16));
+  return {
+    x: Math.max(0, window.innerWidth - width - 20),
+    y: 64,
+    width,
+    height: 300,
+  };
+}
 
 export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
   const { t } = useTranslation();
@@ -47,6 +58,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
 
   // Printer-specific storage key
   const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
+  const minY = useIsSidebarCompact() ? COMPACT_HEADER_HEIGHT : 0;
 
   // Load saved state or use defaults (offset for new viewers without saved state)
   const loadState = (): CameraState => {
@@ -67,14 +79,21 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     }
     // Offset new viewers so they don't stack exactly on top of each other
     const offset = viewerIndex * 30;
+    const initial = defaultState();
     return {
-      ...DEFAULT_STATE,
-      x: Math.max(0, DEFAULT_STATE.x - offset),
-      y: Math.max(0, DEFAULT_STATE.y + offset),
+      ...initial,
+      x: Math.max(0, initial.x - offset),
+      y: Math.max(0, initial.y + offset),
     };
   };
 
   const [state, setState] = useState<CameraState>(loadState);
+  // Below the compact header: a position saved before it held the launcher,
+  // or a window narrowed into the compact layout. Before paint, so a viewer
+  // never shows over the header first.
+  useLayoutEffect(() => {
+    setState(prev => (prev.y < minY ? { ...prev, y: minY } : prev));
+  }, [minY]);
   const [isDragging, setIsDragging] = useState(false);
   const [isResizing, setIsResizing] = useState(false);
   const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
@@ -513,7 +532,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
         setState((prev) => ({
           ...prev,
           x: Math.max(0, Math.min(e.clientX - dragOffset.x, window.innerWidth - prev.width)),
-          y: Math.max(0, Math.min(e.clientY - dragOffset.y, window.innerHeight - prev.height)),
+          y: Math.max(minY, Math.min(e.clientY - dragOffset.y, window.innerHeight - prev.height)),
         }));
       } else if (isResizing && containerRef.current) {
         const rect = containerRef.current.getBoundingClientRect();
@@ -533,7 +552,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
         setState((prev) => ({
           ...prev,
           x: Math.max(0, Math.min(touch.clientX - dragOffset.x, window.innerWidth - prev.width)),
-          y: Math.max(0, Math.min(touch.clientY - dragOffset.y, window.innerHeight - prev.height)),
+          y: Math.max(minY, Math.min(touch.clientY - dragOffset.y, window.innerHeight - prev.height)),
         }));
       } else if (isResizing && containerRef.current) {
         const rect = containerRef.current.getBoundingClientRect();
@@ -564,7 +583,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
         document.removeEventListener('touchcancel', handleMouseUp);
       };
     }
-  }, [isDragging, isResizing, dragOffset]);
+  }, [isDragging, isResizing, dragOffset, minY]);
 
   const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}&viewer=${viewerId}`);
 
@@ -639,6 +658,17 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
               <Fullscreen className="w-3.5 h-3.5 text-bambu-gray" />
             )}
           </button>
+          <button
+            onClick={() => {
+              // The overlay stays when a popup blocker stopped the window.
+              if (openCameraWindow(printerId)) onClose();
+            }}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded"
+            title={t('camera.popOut')}
+            aria-label={t('camera.popOut')}
+          >
+            <ExternalLink className="w-3.5 h-3.5 text-bambu-gray" />
+          </button>
           <button
             onClick={() => setIsMinimized(!isMinimized)}
             className="p-1 hover:bg-bambu-dark-tertiary rounded"

+ 1 - 0
frontend/src/components/KeyboardShortcutsModal.tsx

@@ -64,6 +64,7 @@ function getShortcuts(
       { keys: ['Esc'], description: 'Exit selection mode', isExternal: false },
     ]},
     { category: 'General', items: [
+      { keys: ['C'], description: "Show a printer's camera", isExternal: false },
       { keys: ['?'], description: 'Show this help', isExternal: false },
     ]},
   ];

+ 39 - 2
frontend/src/components/Layout.tsx

@@ -7,6 +7,7 @@ import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { InstallAppButton } from './InstallAppButton';
 import { SwitchbarPopover } from './SwitchbarPopover';
+import { CameraLauncher } from './CameraLauncher';
 import { useQuery, useQueries } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
 import { getIconByName } from './IconPicker';
@@ -97,6 +98,9 @@ export function Layout() {
   // floating disc shows that itself; the compact header's button and the
   // debug-logging banner need telling.
   const [bugReportLogging, setBugReportLogging] = useState(false);
+  // The camera list (#2737), also opened with the C key: from the Printers row
+  // of the sidebar, or from the compact header when the sidebar is a drawer.
+  const [cameraListOpen, setCameraListOpen] = useState(false);
 
   // Theme toggle: mode → icon and tooltip
   const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
@@ -587,6 +591,11 @@ export function Layout() {
           e.preventDefault();
           setShowShortcuts(true);
           break;
+        case 'c':
+        case 'C':
+          e.preventDefault();
+          setCameraListOpen(open => !open);
+          break;
         case 'Escape':
           setShowShortcuts(false);
           break;
@@ -616,10 +625,14 @@ export function Layout() {
             alt="Bambuddy"
             className="h-8 ml-3"
           />
+          {/* A camera from any page (#2737), beside the bug report. */}
+          <div className="ml-auto">
+            <CameraLauncher placement="down" open={cameraListOpen} onOpenChange={setCameraListOpen} />
+          </div>
           {/* Bug report — the compact-layout home of the floating bubble. */}
           <button
             onClick={() => setBugReportOpen(true)}
-            className={`ml-auto p-2 -mr-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+            className={`p-2 -mr-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
               bugReportLogging ? 'text-amber-500' : 'text-red-500'
             }`}
             title={bugReportLogging ? t('bugReport.resumeReport') : t('bugReport.title')}
@@ -733,8 +746,31 @@ export function Layout() {
                 const showBadge = showQueueBadge || showArchiveBadge;
                 const showClearPlateDot = id === 'printers' && needsClearPlate;
 
+                const expanded = isSidebarCompact || sidebarExpanded;
+                // The camera list from any page (#2737), on the row of the page
+                // the cameras belong to. The drawer's copy is not the one the C
+                // key opens: in the compact layout that is the header's.
+                const cameraLauncher = id === 'printers' && (
+                  // Collapsed, the row is 48px wide and the nav clips anything
+                  // past it: a 16px corner button, shown on hover or focus.
+                  <div
+                    className={`absolute ${
+                      expanded
+                        ? 'right-2 top-1/2 -translate-y-1/2'
+                        : `right-0 top-0.5 ${cameraListOpen ? '' : 'opacity-0 group-hover/printers:opacity-100 focus-within:opacity-100'}`
+                    }`}
+                  >
+                    <CameraLauncher
+                      placement="side"
+                      size={expanded ? 'sm' : 'xs'}
+                      onAccent={location.pathname === '/'}
+                      {...(isSidebarCompact ? {} : { open: cameraListOpen, onOpenChange: setCameraListOpen })}
+                    />
+                  </div>
+                );
+
                 return (
-                  <li key={id}>
+                  <li key={id} className={id === 'printers' ? 'relative group/printers' : undefined}>
                     <NavLink
                       to={to}
                       className={({ isActive }) =>
@@ -761,6 +797,7 @@ export function Layout() {
                       </div>
                       {(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}
                     </NavLink>
+                    {cameraLauncher}
                   </li>
                 );
               }

+ 176 - 0
frontend/src/contexts/EmbeddedCameraContext.tsx

@@ -0,0 +1,176 @@
+import { createContext, lazy, Suspense, useCallback, useContext, useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { api } from '../api/client';
+import { useAuth } from './AuthContext';
+import { listenForCameraPopIn, registerCameraTab } from '../utils/camera';
+import { recoverFromMissingChunk } from '../utils/lazyPage';
+import type { EmbeddedCameraViewerProps } from '../components/EmbeddedCameraViewer';
+
+/**
+ * The floating camera overlays, open on every page of the app (#2737).
+ *
+ * They lived in the Printers page, so they vanished on any other page and
+ * only came back on returning to it. Persisted under the same key as then,
+ * so overlays left open before the update are still open after it.
+ */
+const STORAGE_KEY = 'openEmbeddedCameras';
+
+// Loaded on first use: every page now carries the host, and most sessions
+// never open an overlay (#3175 keeps the first load small).
+// A file missing after an update reloads the page, as for a page (lazyPage).
+// One that still fails leaves the overlay out: the host sits above the app's
+// layout, so throwing here would replace every page with the error screen.
+const EmbeddedCameraViewer = lazy(async (): Promise<{ default: ComponentType<EmbeddedCameraViewerProps> }> => {
+  try {
+    const m = await import('../components/EmbeddedCameraViewer');
+    return { default: m.EmbeddedCameraViewer };
+  } catch (error) {
+    if (await recoverFromMissingChunk()) return new Promise<never>(() => {});
+    console.error('Could not load the camera overlay:', error);
+    return { default: () => null };
+  }
+});
+
+export interface OpenCamera {
+  id: number;
+  name: string;
+}
+
+interface EmbeddedCameraContextType {
+  openCameras: OpenCamera[];
+  openEmbeddedCamera: (id: number, name: string) => void;
+  closeEmbeddedCamera: (id: number) => void;
+}
+
+const EmbeddedCameraContext = createContext<EmbeddedCameraContextType | undefined>(undefined);
+
+function readStored(): OpenCamera[] {
+  try {
+    const saved = localStorage.getItem(STORAGE_KEY);
+    if (saved) {
+      const cameras = JSON.parse(saved) as OpenCamera[];
+      if (Array.isArray(cameras)) {
+        return cameras
+          .filter(c => c && Number.isInteger(c.id) && c.id > 0)
+          .map(c => ({ id: c.id, name: String(c.name ?? '') }));
+      }
+    }
+  } catch {
+    // Unreadable storage or a corrupt entry: start with none open.
+  }
+  return [];
+}
+
+/**
+ * Applies one tab's change to the stored list, read fresh each time: every
+ * open tab writes it, and writing this tab's whole list would drop what the
+ * others opened.
+ */
+function updateStored(change: (cameras: OpenCamera[]) => OpenCamera[]) {
+  try {
+    const next = change(readStored());
+    if (next.length > 0) {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+    } else {
+      localStorage.removeItem(STORAGE_KEY);
+    }
+  } catch {
+    // Overlays stay open in this tab; only reopening after a reload is lost.
+  }
+}
+
+export function EmbeddedCameraProvider({ children }: { children: ReactNode }) {
+  const { hasPermission } = useAuth();
+  // Which printers may be shown is only known from the printer list, which
+  // takes printers:read; without it the Printers page, where a camera opened
+  // from before, is out of reach too.
+  const canView = hasPermission('camera:view') && hasPermission('printers:read');
+  const [cameras, setCameras] = useState<Map<number, OpenCamera>>(
+    () => new Map(readStored().map(c => [c.id, c])),
+  );
+
+  const openEmbeddedCamera = useCallback((id: number, name: string) => {
+    setCameras(prev => (prev.has(id) ? prev : new Map(prev).set(id, { id, name })));
+    updateStored(list => (list.some(c => c.id === id) ? list : [...list, { id, name }]));
+  }, []);
+
+  const closeEmbeddedCamera = useCallback((id: number) => {
+    setCameras(prev => {
+      if (!prev.has(id)) return prev;
+      const next = new Map(prev);
+      next.delete(id);
+      return next;
+    });
+    updateStored(list => list.filter(c => c.id !== id));
+  }, []);
+
+  // The list is stored per browser, not per user: a printer that was deleted,
+  // or one the user now signed in may not see, is dropped once the printers
+  // they can see are known, rather than showing up as a broken overlay. Only
+  // a list fetched since this mounted counts: the cache can still hold the
+  // last user's, and a fresh one would not be fetched again by itself.
+  const queryClient = useQueryClient();
+  const [mountedAt] = useState(() => Date.now());
+  const { data: printers, dataUpdatedAt, isSuccess, isError } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+    enabled: canView && cameras.size > 0,
+    // Stale at once, so it is fetched on mount and whenever an overlay opens.
+    staleTime: 0,
+    refetchOnWindowFocus: false,
+  });
+  const listFresh = isSuccess && dataUpdatedAt >= mountedAt;
+  const listChecked = listFresh || isError;
+  useEffect(() => {
+    if (!listFresh || !printers) return;
+    const visible = new Set(printers.map(p => p.id));
+    // Also for an overlay added after the list came in.
+    const dropped = Array.from(cameras.keys()).filter(id => !visible.has(id));
+    dropped.forEach(closeEmbeddedCamera);
+  }, [listFresh, printers, cameras, closeEmbeddedCamera]);
+
+  useEffect(() => {
+    if (!canView) return;
+    // A camera window is told done only for a printer this tab's user can
+    // see, checked against the list as it is now.
+    const canShow = async (printerId: number) => {
+      const list = await queryClient.fetchQuery({ queryKey: ['printers'], queryFn: api.getPrinters, staleTime: 0 });
+      return list.some(p => p.id === printerId);
+    };
+    return listenForCameraPopIn(registerCameraTab(), openEmbeddedCamera, canShow);
+  }, [canView, openEmbeddedCamera, queryClient]);
+
+  const openCameras = useMemo(() => Array.from(cameras.values()), [cameras]);
+  const value = useMemo(
+    () => ({ openCameras, openEmbeddedCamera, closeEmbeddedCamera }),
+    [openCameras, openEmbeddedCamera, closeEmbeddedCamera],
+  );
+
+  return (
+    <EmbeddedCameraContext.Provider value={value}>
+      {children}
+      {/* Several can be open at once; each offsets itself by its index. */}
+      {canView && listChecked && openCameras.length > 0 && (
+        <Suspense fallback={null}>
+          {openCameras.map((camera, index) => (
+            <EmbeddedCameraViewer
+              key={camera.id}
+              printerId={camera.id}
+              printerName={camera.name}
+              viewerIndex={index}
+              onClose={() => closeEmbeddedCamera(camera.id)}
+            />
+          ))}
+        </Suspense>
+      )}
+    </EmbeddedCameraContext.Provider>
+  );
+}
+
+export function useEmbeddedCameras(): EmbeddedCameraContextType {
+  const context = useContext(EmbeddedCameraContext);
+  if (!context) {
+    throw new Error('useEmbeddedCameras must be used inside EmbeddedCameraProvider');
+  }
+  return context;
+}

+ 10 - 0
frontend/src/i18n/locales/de.ts

@@ -3564,9 +3564,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Kameras',
+    noPrinters: 'Keine Drucker',
+    open: 'Kamera anzeigen',
+    close: 'Kamera schließen',
+  },
   camera: {
     title: 'Kameraansicht',
     invalidPrinterId: 'Ungültige Drucker-ID',
+    popOut: 'In eigenem Fenster öffnen',
+    popIn: 'Als Overlay in Bambuddy anzeigen',
+    popInNoTab: 'Es ist kein Bambuddy-Tab geöffnet, in dem die Kamera angezeigt werden kann.',
+    poppedIn: 'Diese Kamera wird jetzt in Bambuddy angezeigt. Du kannst dieses Fenster schließen.',
     live: 'Live',
     snapshot: 'Schnappschuss',
     restartStream: 'Stream neu starten',

+ 10 - 0
frontend/src/i18n/locales/en.ts

@@ -3596,9 +3596,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Cameras',
+    noPrinters: 'No printers',
+    open: 'Show camera',
+    close: 'Close camera',
+  },
   camera: {
     title: 'Camera View',
     invalidPrinterId: 'Invalid printer ID',
+    popOut: 'Open in its own window',
+    popIn: 'Show as an overlay in Bambuddy',
+    popInNoTab: 'No Bambuddy tab is open to show the camera in.',
+    poppedIn: 'This camera is now shown in Bambuddy. You can close this window.',
     live: 'Live',
     snapshot: 'Snapshot',
     restartStream: 'Restart stream',

+ 10 - 0
frontend/src/i18n/locales/es.ts

@@ -3566,9 +3566,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Cámaras',
+    noPrinters: 'Sin impresoras',
+    open: 'Mostrar cámara',
+    close: 'Cerrar cámara',
+  },
   camera: {
     title: 'Vista de la cámara',
     invalidPrinterId: 'ID de impresora no válido',
+    popOut: 'Abrir en su propia ventana',
+    popIn: 'Mostrar como superposición en Bambuddy',
+    popInNoTab: 'No hay ninguna pestaña de Bambuddy abierta para mostrar la cámara.',
+    poppedIn: 'Esta cámara se muestra ahora en Bambuddy. Puedes cerrar esta ventana.',
     live: 'En directo',
     snapshot: 'Captura',
     restartStream: 'Reiniciar la transmisión',

+ 10 - 0
frontend/src/i18n/locales/fr.ts

@@ -3552,9 +3552,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Caméras',
+    noPrinters: 'Aucune imprimante',
+    open: 'Afficher la caméra',
+    close: 'Fermer la caméra',
+  },
   camera: {
     title: 'Vue Caméra',
     invalidPrinterId: 'ID imprimante invalide',
+    popOut: 'Ouvrir dans sa propre fenêtre',
+    popIn: 'Afficher en superposition dans Bambuddy',
+    popInNoTab: 'Aucun onglet Bambuddy n\'est ouvert pour afficher la caméra.',
+    poppedIn: 'Cette caméra est maintenant affichée dans Bambuddy. Vous pouvez fermer cette fenêtre.',
     live: 'Direct',
     snapshot: 'Instantané',
     restartStream: 'Redémarrer le flux',

+ 10 - 0
frontend/src/i18n/locales/it.ts

@@ -3551,9 +3551,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Camere',
+    noPrinters: 'Nessuna stampante',
+    open: 'Mostra camera',
+    close: 'Chiudi camera',
+  },
   camera: {
     title: 'Vista camera',
     invalidPrinterId: 'ID stampante non valido',
+    popOut: 'Apri in una finestra separata',
+    popIn: 'Mostra come overlay in Bambuddy',
+    popInNoTab: 'Nessuna scheda di Bambuddy è aperta per mostrare la camera.',
+    poppedIn: 'Questa camera è ora mostrata in Bambuddy. Puoi chiudere questa finestra.',
     live: 'Live',
     snapshot: 'Istantanea',
     restartStream: 'Riavvia stream',

+ 10 - 0
frontend/src/i18n/locales/ja.ts

@@ -3564,9 +3564,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'カメラ',
+    noPrinters: 'プリンターがありません',
+    open: 'カメラを表示',
+    close: 'カメラを閉じる',
+  },
   camera: {
     title: 'カメラビュー',
     invalidPrinterId: '無効なプリンターID',
+    popOut: '別ウィンドウで開く',
+    popIn: 'Bambuddy でオーバーレイ表示',
+    popInNoTab: 'カメラを表示できる Bambuddy のタブが開いていません。',
+    poppedIn: 'このカメラは Bambuddy に表示されています。このウィンドウは閉じてかまいません。',
     live: 'ライブ',
     snapshot: 'スナップショット',
     restartStream: 'ストリームを再開',

+ 10 - 0
frontend/src/i18n/locales/ko.ts

@@ -3395,9 +3395,19 @@ export default {
     message: '빌드 플레이트에 객체가 감지되었습니다. 인쇄가 자동으로 일시정지되었습니다. 플레이트를 비우고 인쇄를 재개하세요.',
     understand: '확인했습니다'
   },
+  cameraLauncher: {
+    title: '카메라',
+    noPrinters: '프린터 없음',
+    open: '카메라 표시',
+    close: '카메라 닫기',
+  },
   camera: {
     title: '카메라 보기',
     invalidPrinterId: '유효하지 않은 프린터 ID',
+    popOut: '별도 창에서 열기',
+    popIn: 'Bambuddy에서 오버레이로 표시',
+    popInNoTab: '카메라를 표시할 Bambuddy 탭이 열려 있지 않습니다.',
+    poppedIn: '이 카메라는 이제 Bambuddy에 표시됩니다. 이 창을 닫아도 됩니다.',
     live: '실시간',
     snapshot: '스냅샷',
     restartStream: '스트림 재시작',

+ 10 - 0
frontend/src/i18n/locales/nl.ts

@@ -3594,9 +3594,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Camera\'s',
+    noPrinters: 'Geen printers',
+    open: 'Camera tonen',
+    close: 'Camera sluiten',
+  },
   camera: {
     title: 'Cameraweergave',
     invalidPrinterId: 'Ongeldige printer-ID',
+    popOut: 'In een eigen venster openen',
+    popIn: 'Als overlay in Bambuddy tonen',
+    popInNoTab: 'Er is geen Bambuddy-tabblad open om de camera in te tonen.',
+    poppedIn: 'Deze camera wordt nu in Bambuddy getoond. Je kunt dit venster sluiten.',
     live: 'Live',
     snapshot: 'Momentopname',
     restartStream: 'Stream opnieuw starten',

+ 10 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3551,9 +3551,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Câmeras',
+    noPrinters: 'Nenhuma impressora',
+    open: 'Mostrar câmera',
+    close: 'Fechar câmera',
+  },
   camera: {
     title: 'Visualização da Câmera',
     invalidPrinterId: 'ID da impressora inválido',
+    popOut: 'Abrir em uma janela própria',
+    popIn: 'Mostrar como sobreposição no Bambuddy',
+    popInNoTab: 'Nenhuma aba do Bambuddy está aberta para mostrar a câmera.',
+    poppedIn: 'Esta câmera agora é exibida no Bambuddy. Você pode fechar esta janela.',
     live: 'Ao Vivo',
     snapshot: 'Captura',
     restartStream: 'Reiniciar transmissão',

+ 10 - 0
frontend/src/i18n/locales/ru.ts

@@ -3387,9 +3387,19 @@ export default {
     message: "На печатной пластине обнаружены объекты. Печать автоматически приостановлена. Очистите пластину и продолжите печать.",
     understand: "Понятно",
   },
+  cameraLauncher: {
+    title: "Камеры",
+    noPrinters: "Нет принтеров",
+    open: "Показать камеру",
+    close: "Закрыть камеру",
+  },
   camera: {
     title: "Камера",
     invalidPrinterId: "Неверный идентификатор принтера",
+    popOut: "Открыть в отдельном окне",
+    popIn: "Показать поверх Bambuddy",
+    popInNoTab: "Нет открытой вкладки Bambuddy, в которой можно показать камеру.",
+    poppedIn: "Эта камера теперь показана в Bambuddy. Это окно можно закрыть.",
     live: "Прямой эфир",
     snapshot: "Снимок",
     restartStream: "Перезапустить поток",

+ 10 - 0
frontend/src/i18n/locales/sv.ts

@@ -3593,9 +3593,19 @@ errors: {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: 'Kameror',
+    noPrinters: 'Inga skrivare',
+    open: 'Visa kamera',
+    close: 'Stäng kamera',
+  },
   camera: {
     title: 'Kameravy',
     invalidPrinterId: 'Ogiltigt skrivar-ID',
+    popOut: 'Öppna i eget fönster',
+    popIn: 'Visa som överlägg i Bambuddy',
+    popInNoTab: 'Ingen Bambuddy-flik är öppen att visa kameran i.',
+    poppedIn: 'Den här kameran visas nu i Bambuddy. Du kan stänga det här fönstret.',
     live: 'Live',
     snapshot: 'Ögonblicksbild',
     restartStream: 'Starta om strömmen',

+ 10 - 0
frontend/src/i18n/locales/tr.ts

@@ -3567,9 +3567,19 @@ export default {
   },
 
   // Kamera sayfası
+  cameraLauncher: {
+    title: 'Kameralar',
+    noPrinters: 'Yazıcı yok',
+    open: 'Kamerayı göster',
+    close: 'Kamerayı kapat',
+  },
   camera: {
     title: 'Kamera Görünümü',
     invalidPrinterId: 'Geçersiz yazıcı ID\'si',
+    popOut: 'Kendi penceresinde aç',
+    popIn: 'Bambuddy içinde kaplama olarak göster',
+    popInNoTab: 'Kamerayı gösterecek açık bir Bambuddy sekmesi yok.',
+    poppedIn: 'Bu kamera artık Bambuddy içinde gösteriliyor. Bu pencereyi kapatabilirsiniz.',
     live: 'Canlı',
     snapshot: 'Anlık Görüntü',
     restartStream: 'Akışı yeniden başlat',

+ 10 - 0
frontend/src/i18n/locales/uk.ts

@@ -3591,9 +3591,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: "Камери",
+    noPrinters: "Немає принтерів",
+    open: "Показати камеру",
+    close: "Закрити камеру",
+  },
   camera: {
     title: "Вид з камери",
     invalidPrinterId: "Недійсний ідентифікатор принтера",
+    popOut: "Відкрити в окремому вікні",
+    popIn: "Показати поверх Bambuddy",
+    popInNoTab: "Немає відкритої вкладки Bambuddy, у якій можна показати камеру.",
+    poppedIn: "Ця камера тепер показана в Bambuddy. Це вікно можна закрити.",
     live: "Наживо",
     snapshot: "знімок",
     restartStream: "Перезапустити потік",

+ 10 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3551,9 +3551,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: '摄像头',
+    noPrinters: '没有打印机',
+    open: '显示摄像头',
+    close: '关闭摄像头',
+  },
   camera: {
     title: '摄像头视图',
     invalidPrinterId: '无效的打印机 ID',
+    popOut: '在单独窗口中打开',
+    popIn: '在 Bambuddy 中以浮窗显示',
+    popInNoTab: '没有可显示摄像头的 Bambuddy 标签页。',
+    poppedIn: '此摄像头现已在 Bambuddy 中显示。你可以关闭此窗口。',
     live: '实时',
     snapshot: '快照',
     restartStream: '重启流',

+ 10 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3551,9 +3551,19 @@ export default {
   },
 
   // Camera page
+  cameraLauncher: {
+    title: '攝影機',
+    noPrinters: '沒有印表機',
+    open: '顯示攝影機',
+    close: '關閉攝影機',
+  },
   camera: {
     title: '攝影機檢視',
     invalidPrinterId: '無效的印表機 ID',
+    popOut: '在獨立視窗中開啟',
+    popIn: '在 Bambuddy 中以浮動視窗顯示',
+    popInNoTab: '沒有可顯示攝影機的 Bambuddy 分頁。',
+    poppedIn: '此攝影機現已在 Bambuddy 中顯示。你可以關閉此視窗。',
     live: '即時',
     snapshot: '快照',
     restartStream: '重新啟動流',

+ 45 - 1
frontend/src/pages/CameraPage.tsx

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut, Stethoscope } from 'lucide-react';
+import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut, Stethoscope, PictureInPicture2 } from 'lucide-react';
 import { api, getAuthToken, getStreamToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -13,6 +13,7 @@ import { CameraDiagnoseModal } from '../components/CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
 import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
+import { readOpenerTabId, requestCameraPopIn } from '../utils/camera';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
 // The server's Retry-After for a snapshot while a live view starts.
@@ -22,6 +23,23 @@ const MAX_RECONNECT_DELAY = 30000; // 30 seconds
 const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
 
 export function CameraPage() {
+  const { t } = useTranslation();
+  // Back into a Bambuddy tab as an overlay (#2737). The view unmounts, which
+  // stops its stream and every timer it runs, as closing the window would: a
+  // tab the user opened themselves cannot be closed by script, and is left
+  // saying where the camera went.
+  const [poppedIn, setPoppedIn] = useState(false);
+  if (poppedIn) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center p-4">
+        <p className="text-white text-center">{t('camera.poppedIn')}</p>
+      </div>
+    );
+  }
+  return <CameraView onPoppedIn={() => setPoppedIn(true)} />;
+}
+
+function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -667,6 +685,22 @@ export function CameraPage() {
   const sideways = isSideways(printer?.camera_rotation);
   const videoArea = useSidewaysBox(videoAreaRef, sideways && id > 0);
 
+  // Read at load: the tab that opened this window is the one its
+  // sessionStorage was copied from.
+  const [openerTabId] = useState(readOpenerTabId);
+  const [popInPending, setPopInPending] = useState(false);
+  const popIn = async () => {
+    setPopInPending(true);
+    const moved = await requestCameraPopIn(id, printer?.name ?? '', openerTabId);
+    setPopInPending(false);
+    if (!moved) {
+      showToast(t('camera.popInNoTab'), 'warning');
+      return;
+    }
+    onPoppedIn();
+    window.close();
+  };
+
   if (!id) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
@@ -675,6 +709,7 @@ export function CameraPage() {
     );
   }
 
+
   return (
     <div ref={containerRef} className="min-h-screen bg-black flex flex-col">
       {/* Header */}
@@ -746,6 +781,15 @@ export function CameraPage() {
           >
             <Stethoscope className="w-4 h-4 text-bambu-gray" />
           </button>
+          <button
+            onClick={popIn}
+            disabled={popInPending}
+            className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+            title={t('camera.popIn')}
+            aria-label={t('camera.popIn')}
+          >
+            <PictureInPicture2 className="w-4 h-4 text-bambu-gray" />
+          </button>
           <button
             onClick={toggleFullscreen}
             className="p-1.5 hover:bg-bambu-dark-tertiary rounded"

+ 7 - 43
frontend/src/pages/PrintersPage.tsx

@@ -171,7 +171,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar';
 import { FileManagerModal } from '../components/FileManagerModal';
-import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
+import { useEmbeddedCameras } from '../contexts/EmbeddedCameraContext';
 import { CameraWall } from '../components/CameraWall';
 import { DEFAULT_CAM_WALL_TILE_SIZE } from '../components/camWallTileSize';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
@@ -8897,30 +8897,9 @@ export function PrintersPage() {
   const [chosenCameraViewMode, setChosenCameraViewMode] = useState<CameraViewMode | null>(
     () => readStoredCameraViewMode()
   );
-  // Embedded camera viewer state - supports multiple simultaneous viewers
-  // Persisted to localStorage so cameras reopen after navigation
-  const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
-    const saved = localStorage.getItem('openEmbeddedCameras');
-    if (saved) {
-      try {
-        const cameras = JSON.parse(saved) as Array<{ id: number; name: string }>;
-        return new Map(cameras.map(c => [c.id, c]));
-      } catch {
-        return new Map();
-      }
-    }
-    return new Map();
-  });
-
-  // Persist open cameras to localStorage when they change
-  useEffect(() => {
-    const cameras = Array.from(embeddedCameraPrinters.values());
-    if (cameras.length > 0) {
-      localStorage.setItem('openEmbeddedCameras', JSON.stringify(cameras));
-    } else {
-      localStorage.removeItem('openEmbeddedCameras');
-    }
-  }, [embeddedCameraPrinters]);
+  // The overlays themselves are hosted by the layout, so they stay open on
+  // every page (#2737).
+  const { openEmbeddedCamera } = useEmbeddedCameras();
 
   const { data: printers, isLoading } = useQuery({
     queryKey: ['printers'],
@@ -9893,7 +9872,7 @@ export function PrintersPage() {
             // A wall tile has no room for a split button, so it follows the
             // mode the card buttons last chose.
             if (cameraViewMode === 'embedded') {
-              setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }));
+              openEmbeddedCamera(id, name);
             } else {
               openCameraWindow(id);
             }
@@ -9988,7 +9967,7 @@ export function PrintersPage() {
                       onUnassignSpoolmanSpool={(id) => unassignSpoolmanMutation.mutate(id)}
                       timeFormat={settings?.time_format || 'system'}
                       cameraViewMode={cameraViewMode}
-                      onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
+                      onOpenEmbeddedCamera={openEmbeddedCamera}
                       onSelectCameraViewMode={selectCameraViewMode}
                       checkPrinterFirmware={firmwareChecksOn}
                       dryingPresets={effectiveDryingPresets}
@@ -10039,7 +10018,7 @@ export function PrintersPage() {
               } : undefined}
               timeFormat={settings?.time_format || 'system'}
               cameraViewMode={cameraViewMode}
-              onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
+              onOpenEmbeddedCamera={openEmbeddedCamera}
               onSelectCameraViewMode={selectCameraViewMode}
               checkPrinterFirmware={firmwareChecksOn}
               dryingPresets={effectiveDryingPresets}
@@ -10126,21 +10105,6 @@ export function PrintersPage() {
           onCancel={() => setBulkConfirmAction(null)}
         />
       )}
-
-      {/* Embedded Camera Viewers - multiple viewers can be open simultaneously */}
-      {Array.from(embeddedCameraPrinters.values()).map((camera, index) => (
-        <EmbeddedCameraViewer
-          key={camera.id}
-          printerId={camera.id}
-          printerName={camera.name}
-          viewerIndex={index}
-          onClose={() => setEmbeddedCameraPrinters(prev => {
-            const next = new Map(prev);
-            next.delete(camera.id);
-            return next;
-          })}
-        />
-      ))}
     </div>
   );
 }

+ 169 - 2
frontend/src/utils/camera.ts

@@ -52,7 +52,7 @@ export function storeCameraViewMode(mode: CameraViewMode): void {
  * so the browser copies sessionStorage -- which is where the auth token lives
  * -- into the new window. Without it the camera page loads unauthenticated.
  */
-export function openCameraWindow(printerId: number): void {
+export function openCameraWindow(printerId: number): boolean {
   let state: { width?: number; height?: number; left?: number; top?: number } = {
     width: 640,
     height: 400,
@@ -78,5 +78,172 @@ export function openCameraWindow(printerId: number): void {
     .filter(Boolean)
     .join(',');
 
-  window.open(`/camera/${printerId}`, `camera-${printerId}`, features);
+  // False when a popup blocker stopped it, so a caller closing the overlay it
+  // came from can leave the camera where it was.
+  return window.open(`/camera/${printerId}`, `camera-${printerId}`, features) !== null;
+}
+
+/**
+ * Moving a camera window back into a Bambuddy tab as an overlay (#2737).
+ *
+ * The camera window asks over a BroadcastChannel, every tab showing the app
+ * answers, and exactly one of them is told to open the overlay: a window can
+ * be open beside several tabs, and the camera should not appear in each.
+ * The tab the window was opened from is preferred. Its id is in
+ * sessionStorage, which the browser copies into the popup when it opens.
+ */
+const CAMERA_CHANNEL = 'bambuddy-camera';
+const TAB_ID_KEY = 'bambuddyTabId';
+
+type PopInMessage =
+  | { type: 'pop-in-offer'; requestId: string; printerId: number; printerName: string }
+  | { type: 'pop-in-candidate'; requestId: string; tabId: string; instanceId: string }
+  | { type: 'pop-in-claim'; requestId: string; instanceId: string; deadline: number }
+  | { type: 'pop-in-done'; requestId: string }
+  | { type: 'pop-in-refused'; requestId: string; instanceId: string };
+
+function randomId(prefix: string): string {
+  // Math.random: crypto.randomUUID needs a secure context (see useCameraViewerId).
+  return `${prefix}${Math.random().toString(36).slice(2, 12)}`;
+}
+
+/**
+ * This tab's id, kept in sessionStorage so a popup it opens inherits it. It
+ * survives a reload or a sign-in again, so a camera window opened before
+ * still finds the tab it came from.
+ */
+export function registerCameraTab(): string {
+  try {
+    const existing = sessionStorage.getItem(TAB_ID_KEY);
+    if (existing) return existing;
+    const tabId = randomId('t');
+    sessionStorage.setItem(TAB_ID_KEY, tabId);
+    return tabId;
+  } catch {
+    // Without it the window still pops in, just not necessarily to this tab.
+    return randomId('t');
+  }
+}
+
+/** The tab this camera window was opened from, if it was. */
+export function readOpenerTabId(): string | null {
+  try {
+    return sessionStorage.getItem(TAB_ID_KEY);
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Answer camera windows asking to move into a tab; `open` shows the overlay.
+ * Returns the cleanup.
+ *
+ * A duplicated tab starts with a copy of sessionStorage and so the same tab
+ * id; the claim names one listener (`instanceId`), so only one of them opens.
+ * `canShow` is asked before saying done: each tab has its own sign-in, and a
+ * tab whose user cannot see the printer would drop the overlay right after
+ * the window had closed. It refuses instead, and the window asks the next.
+ */
+export function listenForCameraPopIn(
+  tabId: string,
+  open: (printerId: number, printerName: string) => void,
+  canShow: (printerId: number) => Promise<boolean> = async () => true,
+): () => void {
+  if (typeof BroadcastChannel === 'undefined') return () => {};
+  const channel = new BroadcastChannel(CAMERA_CHANNEL);
+  let closed = false;
+  const instanceId = randomId('i');
+  const offers = new Map<string, { printerId: number; printerName: string }>();
+  channel.onmessage = async (event: MessageEvent<PopInMessage>) => {
+    const msg = event.data;
+    if (msg?.type === 'pop-in-offer' && Number.isInteger(msg.printerId) && msg.printerId > 0) {
+      offers.set(msg.requestId, { printerId: msg.printerId, printerName: String(msg.printerName ?? '') });
+      channel.postMessage({ type: 'pop-in-candidate', requestId: msg.requestId, tabId, instanceId } satisfies PopInMessage);
+    } else if (msg?.type === 'pop-in-claim') {
+      if (msg.instanceId !== instanceId) return;
+      const offer = offers.get(msg.requestId);
+      offers.delete(msg.requestId);
+      if (!offer) return;
+      let allowed = false;
+      try {
+        allowed = await canShow(offer.printerId);
+      } catch {
+        allowed = false;
+      }
+      if (closed) return;
+      if (!allowed) {
+        channel.postMessage({ type: 'pop-in-refused', requestId: msg.requestId, instanceId } satisfies PopInMessage);
+        return;
+      }
+      // A claim that reaches a throttled background tab after the window
+      // gave up would show the camera twice: the window kept its own.
+      if (!(Date.now() <= msg.deadline)) return;
+      open(offer.printerId, offer.printerName);
+      channel.postMessage({ type: 'pop-in-done', requestId: msg.requestId } satisfies PopInMessage);
+    } else if (msg?.type === 'pop-in-done') {
+      // Another tab took it; an offer this one was not claimed for is over.
+      offers.delete(msg.requestId);
+    }
+  };
+  return () => {
+    closed = true;
+    channel.close();
+  };
+}
+
+/**
+ * Ask a Bambuddy tab to take this camera as an overlay. Resolves true once one
+ * has opened it, false when no tab answered (none open, or all signed out),
+ * none could show the printer, or the claimed one did not open it in time.
+ */
+export function requestCameraPopIn(
+  printerId: number,
+  printerName: string,
+  preferredTabId: string | null,
+  { answerMs = 400, doneMs = 3000 }: { answerMs?: number; doneMs?: number } = {},
+): Promise<boolean> {
+  if (typeof BroadcastChannel === 'undefined') return Promise.resolve(false);
+  const channel = new BroadcastChannel(CAMERA_CHANNEL);
+  const requestId = randomId('r');
+  return new Promise<boolean>((resolve) => {
+    const candidates: Array<{ tabId: string; instanceId: string }> = [];
+    let claimedId: string | null = null;
+    let timer: ReturnType<typeof setTimeout>;
+    const finish = (ok: boolean) => {
+      clearTimeout(timer);
+      channel.close();
+      resolve(ok);
+    };
+    const claimNext = () => {
+      clearTimeout(timer);
+      const target = candidates.find(c => c.tabId === preferredTabId) ?? candidates[0];
+      if (!target) {
+        finish(false);
+        return;
+      }
+      candidates.splice(candidates.indexOf(target), 1);
+      claimedId = target.instanceId;
+      // The tab must open it before this window stops waiting; a margin
+      // covers the done message's own trip back.
+      const deadline = Date.now() + doneMs / 2;
+      channel.postMessage({ type: 'pop-in-claim', requestId, instanceId: target.instanceId, deadline } satisfies PopInMessage);
+      timer = setTimeout(() => finish(false), doneMs);
+    };
+    channel.onmessage = (event: MessageEvent<PopInMessage>) => {
+      const msg = event.data;
+      if (msg?.requestId !== requestId) return;
+      if (msg.type === 'pop-in-candidate') {
+        // Kept after a claim too: a tab that refuses hands on to the others.
+        candidates.push({ tabId: msg.tabId, instanceId: msg.instanceId });
+        // The tab it came from needs no waiting for the others.
+        if (claimedId === null && preferredTabId && msg.tabId === preferredTabId) claimNext();
+      } else if (msg.type === 'pop-in-refused' && msg.instanceId === claimedId) {
+        claimNext();
+      } else if (msg.type === 'pop-in-done') {
+        finish(true);
+      }
+    };
+    timer = setTimeout(claimNext, answerMs);
+    channel.postMessage({ type: 'pop-in-offer', requestId, printerId, printerName } satisfies PopInMessage);
+  });
 }

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ArchivesPage-BK9OwB_q.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ArchivesPage-D2d9XGOW.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AssignToAmsModal-DTLU3pN7.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AuthContext-DM3YxV_q.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AuthContext-XuzzSfKE.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CamWallPage-CcCdDeFC.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CamWallPage-Dc7zAekP.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraDiagnoseModal-BydfnYfD.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraDiagnoseModal-CZO_D5up.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraPage-14haujuN.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraPage-BJQnxW0i.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraWall-DBrA6v1h.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraWall-OtS7h9wP.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CQ5519i0.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ConfirmOutcomeDialog-C6HIdza4.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ConnectAuthorizePage-CHor995a.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/EmbeddedCameraViewer-aZnEbFDI.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ExternalLinkPage-Lcr-VcSz.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
static/assets/FileManagerPage-B8h6rQf-.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
static/assets/FileManagerPage-D-O29HVu.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/FinancePage-DNe-MhiO.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/FinancePage-bVN1geRz.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/InventoryPage-DED8lADO.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/InventoryPage-Dl4gQUjs.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/Layout-5XmJ6sFu.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/Layout-DfNbRVI6.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/LibraryTrashPage-zTng2FQV.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/LoginPage-CWtJOR7T.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/MaintenancePage-XeSE9RRD.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ModelSourcesPage-BMef_jNQ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/NotificationsPage-BUAtuAOt.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PhotoGalleryModal-8Mu8hW3P.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintModal-CZCHIZi7.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrinterLocationsPage-dQrnPQMO.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintersPage-BMqz0Ozs.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintersPage-D2kD7-mc.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProfilesPage-CtxTLlQr.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProjectDetailPage-B6zD7j31.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProjectDetailPage-DOLWJQsR.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProjectsPage-BxbYXMaN.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/QueuePage-BWrK8xh_.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/QueuePage-D8PxEvf8.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/RichTextEditor-DRq8sA5e.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SettingsPage-3-NATkhb.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SettingsPage-BUVSoK4T.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SetupPage-CnhYrJK5.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SkipObjectsModal-CXP7UaJN.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SliceModal-CaTpFeKv.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CzceN3rX.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyDashboard-CScPvPE5.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CldnCSnF.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BzhEc5TM.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-C_60sPa8.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/StatsPage-EoL4WVGs.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/StreamOverlayPage-BPYmo-Mp.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/StreamOverlayPage-BxAJ-hoc.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SystemInfoPage-_GIClDVV.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ThemeContext-e7wt-avj.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/camera-DVxLwUaj.js


+ 1 - 1
static/assets/currency-DS4qMz4d.js → static/assets/currency-R7oUEamO.js

@@ -1 +1 @@
-var e={USD:`$`,EUR:`€`,GBP:`£`,CHF:`Fr.`,JPY:`¥`,CNY:`¥`,CAD:`$`,AUD:`$`,INR:`₹`,HKD:`HK$`,KRW:`₩`,SEK:`kr`,NOK:`kr`,DKK:`kr`,PLN:`zł`,BRL:`R$`,TWD:`NT$`,SGD:`S$`,NZD:`NZ$`,MXN:`MX$`,BZD:`BZ$`,MYR:`RM`,CZK:`Kč`,THB:`฿`,ZAR:`R`,TRY:`₺`,RUB:`₽`,HUF:`Ft`,ILS:`₪`,UAH:`₴`,IDR:`Rp`,PHP:`₱`};function t(t){return e[t.toUpperCase()]||t}var n=Object.entries(e).map(([e,t])=>({code:e,label:`${e} (${t})`}));export{t as n,n as t};
+var e={USD:`$`,EUR:`€`,GBP:`£`,CHF:`Fr.`,JPY:`¥`,CNY:`¥`,CAD:`$`,AUD:`$`,INR:`₹`,HKD:`HK$`,KRW:`₩`,SEK:`kr`,NOK:`kr`,DKK:`kr`,PLN:`zł`,BRL:`R$`,TWD:`NT$`,SGD:`S$`,NZD:`NZ$`,MXN:`MX$`,BZD:`BZ$`,MYR:`RM`,CZK:`Kč`,THB:`฿`,ZAR:`R`,TRY:`₺`,RUB:`₽`,HUF:`Ft`,ILS:`₪`,UAH:`₴`,IDR:`Rp`,PHP:`₱`,EGP:`E£`};function t(t){return e[t.toUpperCase()]||t}var n=Object.entries(e).map(([e,t])=>({code:e,label:`${e} (${t})`}));export{t as n,n as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/de-BikatwOZ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/de-CFWeH8Ul.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/es-Cchn5VB9.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/es-L-2yOMsc.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/fr-BOnzRK4I.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/fr-BT_J_zVM.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-DAazpymq.css


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-DPuWk1vL.js


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff