From 61a6ed1f20c6d6754868d637dc8785290cb422fb Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 16 Aug 2026 13:43:15 +0200 Subject: [PATCH] Move Camera View Mode from Settings onto the camera button Whether a camera opened in its own browser window or as a floating overlay was one dropdown in Settings > General > Camera, applied to every camera on the install. Deciding per printer meant leaving the Printers page, changing the setting, coming back, opening the camera, and going back again to undo it -- five steps for a choice you make while looking at the printer you want to watch. The camera button on the printer card is now a split control. The icon opens the camera whichever way you opened the last one; the caret beside it offers both modes, and picking one opens the camera that way as well as making it the mode the icon uses from then on. A menu that only changed a preference would have left the user a second click to do the thing they had already asked for. The mode in effect is ticked. The choice lives in the user's own browser, so two people watching the same farm can each have the view they want. camera_view_mode survives as the default a browser that has never chosen starts from, and is written back when the user holds settings:update. The local value wins on read: someone below that permission cannot write theirs back, and a preference that silently reverted on the next render would be worse than none. Two things were consolidated on the way through. The popup-opening code -- saved geometry, and deliberately no noopener so the browser copies sessionStorage and its auth token into the new window -- was duplicated between the printer card and the Cam Wall tile handler; it is now utils/camera, with the geometry parse wrapped so a corrupt cameraWindowState falls back to defaults instead of throwing, which neither copy did. The Cam Wall follows the same remembered mode, since a tile has no room for a split button of its own. The effect that force-closed every open overlay when the setting flipped to window is gone. It made sense for a global switch; with the choice made per click, closing viewers someone deliberately opened does not. No new locale strings: the four the settings control used are reused as the menu's labels and tooltips and the caret's own label, so all 13 locales stay in parity untouched. --- CHANGELOG.md | 1 + .../PrintersPageCameraSplitButton.test.tsx | 224 +++++++++++++++ .../src/__tests__/pages/SettingsPage.test.tsx | 13 + frontend/src/pages/PrintersPage.tsx | 183 ++++++++---- frontend/src/pages/SettingsPage.tsx | 26 +- frontend/src/utils/camera.ts | 82 ++++++ static/assets/index-C_NRLB80.css | 2 + .../{index-61kdXEBh.js => index-Cxc_iG-w.js} | 260 +++++++++--------- static/assets/index-VSpFxsmE.css | 2 - static/index.html | 4 +- 10 files changed, 592 insertions(+), 205 deletions(-) create mode 100644 frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx create mode 100644 frontend/src/utils/camera.ts create mode 100644 static/assets/index-C_NRLB80.css rename static/assets/{index-61kdXEBh.js => index-Cxc_iG-w.js} (79%) delete mode 100644 static/assets/index-VSpFxsmE.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 12eff50c6..11f115e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ All notable changes to Bambuddy will be documented in this file. - **The MQTT debug log now records the commands sent to a printer, not only what it reports back** — **Printer → Debug → MQTT** captured one side of the conversation. Bambuddy listens on both of a printer's topics, but the one carrying commands returned before anything was written to the log, so a capture could show every status push the printer made and nothing it was ever told — including the commands Bambu Studio sends over the local network, which is the only place they can be observed at all. Those now appear alongside Bambuddy's own, grouped under the outgoing filter. It is what lets a question like "which value does Studio put in this field?" be answered from a user's capture instead of guessed at, and it is why #2774 could not be taken further. Commands Bambuddy sends appear twice, once as it publishes and once as the broker echoes it back, and the pair is itself evidence the command reached the broker. Logging is off until switched on, as before. Covered by backend tests. ### Changed +- **The camera button picks its own view mode, replacing the Camera View Mode setting** — Whether a camera opened in its own browser window or as a floating overlay was one switch in **Settings** > **General** > **Camera**, applied to every camera on the install. Choosing per printer meant going to another page and back, and then back again to undo it, which is a lot of clicks for something you decide while looking at the printer. The camera button on the printer card is now a split control: the icon opens whichever mode you used last, and the caret beside it offers both, opening the camera that way and remembering it for next time. The mode in effect is ticked in the menu. Your last choice is kept in your own browser, so two people watching the same farm can each have the view they want; the old setting survives behind the scenes as the default a browser that has never chosen starts from, and is written back when you have permission to change settings. The Cam Wall follows the same choice, since a tile has no room for a split button of its own. Wiki updated. Covered by frontend tests. - **The L and XL printer cards now scale their text and icons, not just their width (#1848, reporter @misterff1)** — Switching a card from M to XL made it wider, enlarged the printer name and the thumbnail, and left everything else exactly as it was: the AMS slot labels, temperatures, filament names, status text and every small button stayed pinned between 8 and 11 pixels, well under the smallest size used anywhere else in Bambuddy. The result was a full-width card carrying the same tiny text as the compact one, which is precisely the opposite of what someone reaching for a bigger card is asking for. Browser zoom is not an answer to this, since it enlarges the entire page and so preserves the very disparity being complained about. The card body now scales along with the card: L draws it 20% larger and XL 40% larger, icons included, so the controls grow with the text rather than staying fiddly to hit. The AMS-HT card needed two adjustments of its own, since its temperature and humidity readings sit beside the slot rather than under it. Its single slot was the only thing on that row able to grow, so it swallowed every spare pixel and pushed the readings hard against the card's edge — it is now capped at roughly two ordinary slots, which keeps them clear at any card width. The card itself also gained a ceiling of one full AMS card's width, so a unit that wraps onto a line of its own no longer stretches that single slot across the whole card. S and M are deliberately untouched — S is the dense fleet view where density is the point, and M is the default, so an existing install looks identical until you reach for a size that is already asking for more room. Wiki updated. Covered by frontend tests. - **The H2C nozzle rack card sizes itself to its contents and numbers its slots** — The rack card shared a row with the nozzle, bed and chamber readings but was given a 190-pixel floor plus twice their growth share to draw six fixed 28-pixel chips. On anything wider than a compact card it claimed several hundred pixels and left most of them empty, and the width came out of the cards that needed it — a combined dual-nozzle reading was wrapping "220 / 220" onto two lines beside a mostly blank rack. It now takes its content width and gives the remainder back, while still giving way on a narrow card rather than overflowing. Each slot also carries its physical rack position, 1 to 6, below the chip, so a nozzle can be named rather than counted along; the numbering is positional, so an empty slot keeps its number and "the nozzle in slot 4" means the same thing however many of the six are occupied. The chips also scale with the card now: they were a hard-coded 28 pixels at every size while the body type and icons around them grow by 20% at L and 40% at XL, so setting the card larger left a shrunken strip beside neighbours that had grown around it, with the diameter figures pressing against chip edges that had not moved. - **The process-settings panel shows the preset's own values, and says why when it cannot** — The panel baselined every field on the option schema's compiled-in defaults, so a preset setting a 0.42 mm line width displayed 0 — the C++ default meaning "derive from the nozzle". Every field was affected; the line-width group just made it obvious. Bambuddy cannot answer this on its own: a standard-tier pick is only an `inherits` stub on our side, and local or cloud presets are deltas whose remainder lives in the profile tree bundled inside the running sidecar. The values now come from the sidecar's own resolve endpoint, which runs the same resolver a slice does against the same profiles, so what the panel shows cannot disagree with what a slice produces. Deliberately not the local profile resolver, which walks OrcaSlicer's published tree and can differ from the image actually installed. An untouched field shows the preset's value and reverting returns to it, and the modified marker compares against that baseline too, so fields the preset moved off the C++ default are no longer flagged as user edits. When the values cannot be read, the panel now names which of the four causes applied instead of collapsing them into one message. The overwhelmingly common one has an obvious fix and is not an edge case: an install pulls its sidecar as `latest` whatever channel Bambuddy is on, so a current Bambuddy talking to a sidecar that predates the resolve endpoint is the normal state rather than a misconfiguration, and those users would otherwise have seen an amber warning on every slice with nothing pointing at the sidecar image. diff --git a/frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx b/frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx new file mode 100644 index 000000000..8d9a789ee --- /dev/null +++ b/frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx @@ -0,0 +1,224 @@ +/** + * The printer card's camera button chooses its own view mode. + * + * Window-vs-overlay used to be one switch in Settings > General > Camera, so + * watching one printer in an overlay and another in its own window meant a trip + * to another page and back. The button is now a split control: the icon opens + * whichever mode was used last, the caret picks between the two. The stored + * setting survives only as the default a browser that has never chosen starts + * from -- the local choice wins after that, because a user without + * settings:update cannot write theirs back and would otherwise never keep one. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +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 mockUseAuth = { + user: { id: 1, username: 'operator', permissions: [] as string[] }, + authEnabled: true, + requiresSetup: false, + loading: false, + isAdmin: false, + login: vi.fn(), + loginWithToken: vi.fn(), + logout: vi.fn(), + refreshUser: vi.fn(), + refreshAuth: vi.fn(), + hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)), + hasAnyPermission: vi.fn(() => true), + hasAllPermissions: vi.fn(() => true), + canModify: vi.fn(() => true), +}; + +vi.mock('../../contexts/AuthContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAuth: () => mockUseAuth }; +}); + +import { render } from '../utils'; +import { PrintersPage } from '../../pages/PrintersPage'; + +const mockPrinter = { + id: 1, + name: 'X1C', + ip_address: '192.168.1.100', + serial_number: '01P00A000000001', + access_code: '12345678', + model: 'X1C', + enabled: true, + nozzle_diameter: 0.4, + nozzle_type: 'stainless_steel', + location: 'Workshop', + auto_archive: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +/** Bodies of every PUT /settings the page made, so the write-back is checkable. */ +const settingsWrites: Record[] = []; + +function renderPage(storedMode: 'window' | 'embedded' = 'window') { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])), + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ + connected: true, + state: 'IDLE', + progress: 0, + layer_num: 0, + total_layers: 0, + temperatures: { nozzle: 25, bed: 25, chamber: 25 }, + remaining_time: 0, + filename: null, + wifi_signal: -29, + speed_level: 2, + vt_tray: [], + ams: [], + }) + ), + http.get('/api/v1/queue/', () => HttpResponse.json([])), + http.get('/api/v1/settings/ui-preferences', () => + HttpResponse.json({ camera_view_mode: storedMode }) + ), + http.put('/api/v1/settings/', async ({ request }) => { + const body = (await request.json()) as Record; + settingsWrites.push(body); + return HttpResponse.json(body); + }), + ); + return render(); +} + +/** The camera icon, whichever of the two modes it currently promises. */ +async function cameraIcon(): Promise { + await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull()); + const el = + screen.queryByTitle('Open camera in new window') ?? screen.queryByTitle('Open camera overlay'); + expect(el).not.toBeNull(); + return el as HTMLElement; +} + +async function openModeMenu(): Promise { + await cameraIcon(); + fireEvent.click(screen.getByLabelText('Camera View Mode')); +} + +let openSpy: ReturnType; + +/** + * A real store behind the suite-wide localStorage mock, which is otherwise a + * set of no-op vi.fn()s -- so setItem would be forgotten and getItem would hand + * back undefined, and "remembers the choice" could not be tested at all. + */ +const storage = new Map(); + +describe('PrintersPage — camera split button', () => { + beforeEach(() => { + settingsWrites.length = 0; + permissions.granted = ['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) => { + storage.set(key, value); + }); + vi.mocked(localStorage.removeItem).mockImplementation((key: string) => { + storage.delete(key); + }); + openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it('opens a separate window when that is the mode in effect', async () => { + renderPage('window'); + fireEvent.click(await cameraIcon()); + + expect(openSpy).toHaveBeenCalledWith( + '/camera/1', + 'camera-1', + expect.stringContaining('width=640') + ); + }); + + it('offers both modes from the caret', async () => { + renderPage('window'); + await openModeMenu(); + + expect(await screen.findByText(/New Window/)).toBeInTheDocument(); + expect(await screen.findByText(/Embedded Overlay/)).toBeInTheDocument(); + }); + + it('opens the overlay when the overlay is picked, instead of a window', async () => { + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + // "Refresh stream" is the overlay's own control; the card has no such button. + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('remembers the picked mode for the next visit', async () => { + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + await waitFor(() => expect(storage.get('cameraViewMode')).toBe('embedded')); + }); + + it('uses a remembered choice over the stored setting', async () => { + // The case that makes the local choice authoritative: the install-wide + // default still says window, but this browser has asked for the overlay. + storage.set('cameraViewMode', 'embedded'); + renderPage('window'); + fireEvent.click(await cameraIcon()); + + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the stored setting in a browser that has never chosen', async () => { + renderPage('embedded'); + await waitFor(() => expect(screen.queryByTitle('Open camera overlay')).not.toBeNull()); + fireEvent.click(await cameraIcon()); + + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('marks which mode the plain icon will use', async () => { + storage.set('cameraViewMode', 'embedded'); + renderPage('window'); + await openModeMenu(); + + expect(await screen.findByText('Embedded Overlay ✓')).toBeInTheDocument(); + expect(await screen.findByText('New Window')).toBeInTheDocument(); + }); + + it('saves the pick as the install-wide default when allowed to', async () => { + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + await waitFor(() => expect(settingsWrites).toEqual([{ camera_view_mode: 'embedded' }])); + }); + + 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']; + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + await waitFor(() => expect(storage.get('cameraViewMode')).toBe('embedded')); + expect(settingsWrites).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index c8824ae39..57ecd78e6 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/frontend/src/__tests__/pages/SettingsPage.test.tsx @@ -192,6 +192,19 @@ describe('SettingsPage', () => { }); }); + it('no longer offers Camera View Mode, which moved to the printer card', async () => { + // The camera button on each printer card is a split control now, so the + // choice is made per stream rather than once for the whole install. The + // External Cameras section is still here, which is what keeps this from + // passing merely because the Camera card failed to render. + render(); + + await waitFor(() => { + expect(screen.getByText('External Cameras')).toBeInTheDocument(); + }); + expect(screen.queryByText('Camera View Mode')).toBeNull(); + }); + it('shows default printer setting', async () => { render(); diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 2bd727cfd..c025d50da 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -3,6 +3,12 @@ import { createPortal } from 'react-dom'; import { compareFwVersions } from '../utils/firmwareVersion'; import { formatPrintName } from '../utils/printName'; import { computePopoverPosition, type PopoverPosition } from '../utils/popoverPosition'; +import { + openCameraWindow, + readStoredCameraViewMode, + storeCameraViewMode, + type CameraViewMode, +} from '../utils/camera'; import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets'; import { computeStartAfter, type DryingStartMode } from '../lib/scheduledDrying'; import { @@ -132,6 +138,7 @@ import { LayoutGrid, MonitorPlay, ExternalLink, + PictureInPicture2, } from 'lucide-react'; // Aliased: lucide-react already exports a `Link` icon into this module. @@ -146,6 +153,7 @@ import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinter import { FileManagerModal } from '../components/FileManagerModal'; import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer'; import { CameraWall } from '../components/CameraWall'; +import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu'; import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal'; import { AiDetectionModal } from '../components/AiDetectionModal'; @@ -2013,6 +2021,7 @@ function PrinterCard({ timeFormat = 'system', cameraViewMode = 'window', onOpenEmbeddedCamera, + onSelectCameraViewMode, checkPrinterFirmware = true, dryingPresets = DRYING_PRESETS, requirePlateClear = false, @@ -2052,8 +2061,9 @@ function PrinterCard({ spoolmanLoading?: boolean; onUnassignSpoolmanSpool?: (spoolmanSpoolId: number) => void; timeFormat?: 'system' | '12h' | '24h'; - cameraViewMode?: 'window' | 'embedded'; + cameraViewMode?: CameraViewMode; onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void; + onSelectCameraViewMode?: (mode: CameraViewMode) => void; checkPrinterFirmware?: boolean; dryingPresets?: Record; requirePlateClear?: boolean; @@ -2179,6 +2189,11 @@ function PrinterCard({ const [isDraggingFile, setIsDraggingFile] = useState(false); const [isDropUploading, setIsDropUploading] = useState(false); const printerActionsMenuRef = useRef(null); + // Viewport coordinates, because the camera mode menu is a `ContextMenu` at + // `position: fixed`. The button sits in the card's footer, so a menu drawn + // inside the card would have to open upward into the card body; anchored to + // the viewport it escapes the card and the grid's scroll container both. + const [cameraMenuAnchor, setCameraMenuAnchor] = useState<{ x: number; y: number } | null>(null); const dragCounterRef = useRef(0); const [amsHistoryModal, setAmsHistoryModal] = useState<{ amsId: number; @@ -3373,6 +3388,48 @@ function PrinterCard({ const footerActionButtonClass = '!h-8 !min-h-8 !px-2 !py-0'; const footerIconButtonClass = '!h-8 !min-h-8 !w-8 !px-0 !py-0'; + + // Opening a camera in a given mode, without touching which mode is remembered + // -- the split button's two halves want the same action but disagree about + // whether the choice was deliberate enough to keep. + const openCameraIn = (mode: CameraViewMode) => { + if (mode === 'embedded' && onOpenEmbeddedCamera) { + onOpenEmbeddedCamera(printer.id, printer.name); + } else { + openCameraWindow(printer.id); + } + }; + + // Picking a mode both opens the camera that way and makes it the mode the + // plain icon uses from now on. A menu that only changed a preference would + // leave the user with a second click to do the thing they already asked for. + const cameraViewModeMenuItems: ContextMenuItem[] = ( + [ + { + mode: 'window' as const, + icon: , + label: t('settings.newWindow'), + title: t('settings.cameraWindowDescription'), + }, + { + mode: 'embedded' as const, + icon: , + label: t('settings.embeddedOverlay'), + title: t('settings.cameraOverlayDescription'), + }, + ] + ).map(({ mode, icon, label, title }) => ({ + icon, + // The remembered mode is marked in the label itself: the icon slot already + // carries the mode's own icon, and a separate tick column would push both + // entries out of line with every other card menu. + label: mode === cameraViewMode ? `${label} ✓` : label, + title, + onClick: () => { + onSelectCameraViewMode?.(mode); + openCameraIn(mode); + }, + })); const renderAmsSlotActions = ({ amsId, slotId, @@ -6271,35 +6328,50 @@ function PrinterCard({
{printerActionsMenu}
- {/* Camera Button */} - + {/* Camera split button: the icon opens whichever view was used + last, the caret picks between the two and remembers it. + Replaces the old Settings > General > Camera switch, which + made choosing between an overlay and a window a trip to + another page for something you decide per camera. */} +
+ + +
+ {cameraMenuAnchor && ( + setCameraMenuAnchor(null)} + /> + )}