Просмотр исходного кода

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.
maziggy 3 недель назад
Родитель
Сommit
61a6ed1f20

+ 1 - 0
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.

+ 224 - 0
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<typeof import('../../contexts/AuthContext')>();
+  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<string, unknown>[] = [];
+
+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<string, unknown>;
+      settingsWrites.push(body);
+      return HttpResponse.json(body);
+    }),
+  );
+  return render(<PrintersPage />);
+}
+
+/** The camera icon, whichever of the two modes it currently promises. */
+async function cameraIcon(): Promise<HTMLElement> {
+  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<void> {
+  await cameraIcon();
+  fireEvent.click(screen.getByLabelText('Camera View Mode'));
+}
+
+let openSpy: ReturnType<typeof vi.spyOn>;
+
+/**
+ * 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<string, string>();
+
+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([]);
+  });
+});

+ 13 - 0
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(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('External Cameras')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Camera View Mode')).toBeNull();
+    });
+
     it('shows default printer setting', async () => {
       render(<SettingsPage />);
 

+ 133 - 50
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<string, DryingPreset>;
   requirePlateClear?: boolean;
@@ -2179,6 +2189,11 @@ function PrinterCard({
   const [isDraggingFile, setIsDraggingFile] = useState(false);
   const [isDropUploading, setIsDropUploading] = useState(false);
   const printerActionsMenuRef = useRef<HTMLDivElement>(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: <ExternalLink className="w-4 h-4" />,
+        label: t('settings.newWindow'),
+        title: t('settings.cameraWindowDescription'),
+      },
+      {
+        mode: 'embedded' as const,
+        icon: <PictureInPicture2 className="w-4 h-4" />,
+        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({
             <div className="flex items-center justify-between gap-2">
               {printerActionsMenu}
               <div className="flex items-center justify-end gap-2 flex-wrap">
-                {/* Camera Button */}
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => {
-                    if (cameraViewMode === 'embedded' && onOpenEmbeddedCamera) {
-                      onOpenEmbeddedCamera(printer.id, printer.name);
-                    } else {
-                      // Use saved window state or defaults
-                      const saved = localStorage.getItem('cameraWindowState');
-                      const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
-                      const features = [
-                        `width=${state.width}`,
-                        `height=${state.height}`,
-                        state.left !== undefined ? `left=${state.left}` : '',
-                        state.top !== undefined ? `top=${state.top}` : '',
-                        // No `noopener`: same-origin popup needs opener so the browser
-                        // copies sessionStorage (auth token) into the new window.
-                        'menubar=no,toolbar=no,location=no,status=no',
-                      ].filter(Boolean).join(',');
-                      window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
-                    }
-                  }}
-                  disabled={!status?.connected || !hasPermission('camera:view')}
-                  title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : (cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow'))}
-                  className={footerIconButtonClass}
-                >
-                  <Video className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
-                </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. */}
+                <div className="flex items-center flex-shrink-0">
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => openCameraIn(cameraViewMode)}
+                    disabled={!status?.connected || !hasPermission('camera:view')}
+                    title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : (cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow'))}
+                    className={`${footerIconButtonClass} !rounded-r-none`}
+                  >
+                    <Video className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+                  </Button>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={(e) => {
+                      // No open/close toggle: the menu's own outside-mousedown
+                      // handler has already dismissed it by the time this lands.
+                      const rect = e.currentTarget.getBoundingClientRect();
+                      setCameraMenuAnchor({ x: rect.left, y: rect.bottom + 4 });
+                    }}
+                    disabled={!status?.connected || !hasPermission('camera:view')}
+                    title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : t('settings.cameraViewMode')}
+                    aria-label={t('settings.cameraViewMode')}
+                    // Not footerIconButtonClass plus an override: both widths
+                    // would carry !important and the stylesheet's own order,
+                    // not this one, would decide which won.
+                    className="!h-8 !min-h-8 !w-6 !px-0 !py-0 !rounded-l-none border-l border-bambu-dark"
+                  >
+                    <ChevronDown className="w-3 h-3" />
+                  </Button>
+                </div>
+                {cameraMenuAnchor && (
+                  <ContextMenu
+                    x={cameraMenuAnchor.x}
+                    y={cameraMenuAnchor.y}
+                    items={cameraViewModeMenuItems}
+                    onClose={() => setCameraMenuAnchor(null)}
+                  />
+                )}
                 <Button
                   variant="secondary"
                   size="sm"
@@ -8456,10 +8528,15 @@ export function PrintersPage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
+  // Which way the camera buttons open a stream. Chosen per click from the
+  // button's own menu; null until this browser has made a choice, so the
+  // stored setting below can act as the default a fresh browser starts from.
+  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 }>>(() => {
-    // Initialize from localStorage if camera_view_mode is embedded
     const saved = localStorage.getItem('openEmbeddedCameras');
     if (saved) {
       try {
@@ -8528,12 +8605,24 @@ export function PrintersPage() {
     return DRYING_PRESETS;
   }, [settings?.drying_presets]);
 
-  // Close embedded cameras if mode changes to 'window'
-  useEffect(() => {
-    if (settings?.camera_view_mode === 'window' && embeddedCameraPrinters.size > 0) {
-      setEmbeddedCameraPrinters(new Map());
+  // The local choice wins over the stored one: someone without settings:update
+  // cannot write their pick back, and a preference that silently reverted on
+  // the next render would be worse than none at all.
+  const cameraViewMode: CameraViewMode =
+    chosenCameraViewMode ?? (settings?.camera_view_mode === 'embedded' ? 'embedded' : 'window');
+
+  const selectCameraViewMode = useCallback((mode: CameraViewMode) => {
+    storeCameraViewMode(mode);
+    setChosenCameraViewMode(mode);
+    // Best effort, and only a default for other browsers -- the choice is
+    // already in effect here. Users below settings:update simply keep theirs
+    // locally, which is why the failure is logged rather than surfaced.
+    if (hasPermission('settings:update')) {
+      api.updateSettings({ camera_view_mode: mode })
+        .then(() => queryClient.invalidateQueries({ queryKey: ['ui-preferences'] }))
+        .catch((err) => console.warn('Could not save camera view mode as the default:', err));
     }
-  }, [settings?.camera_view_mode, embeddedCameraPrinters.size]);
+  }, [hasPermission, queryClient]);
 
   // Fetch all smart plugs to know which printers have them
   const { data: smartPlugs } = useQuery({
@@ -9431,20 +9520,12 @@ export function PrintersPage() {
           maxLive={camWallMaxLive}
           snapshotIntervalSec={camWallSnapshotSec}
           onTileClick={(id, name) => {
-            const cameraMode = settings?.camera_view_mode || 'window';
-            if (cameraMode === 'embedded') {
+            // 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 }));
             } else {
-              const saved = localStorage.getItem('cameraWindowState');
-              const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
-              const features = [
-                `width=${state.width}`,
-                `height=${state.height}`,
-                state.left !== undefined ? `left=${state.left}` : '',
-                state.top !== undefined ? `top=${state.top}` : '',
-                'menubar=no,toolbar=no,location=no,status=no',
-              ].filter(Boolean).join(',');
-              window.open(`/camera/${id}`, `camera-${id}`, features);
+              openCameraWindow(id);
             }
           }}
           statusMode={camWallStatusMode}
@@ -9538,8 +9619,9 @@ export function PrintersPage() {
                       spoolmanLoading={spoolmanSpoolsLoading || spoolmanAssignmentsLoading}
                       onUnassignSpoolmanSpool={(id) => unassignSpoolmanMutation.mutate(id)}
                       timeFormat={settings?.time_format || 'system'}
-                      cameraViewMode={settings?.camera_view_mode || 'window'}
+                      cameraViewMode={cameraViewMode}
                       onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
+                      onSelectCameraViewMode={selectCameraViewMode}
                       checkPrinterFirmware={settings?.check_printer_firmware !== false}
                       dryingPresets={effectiveDryingPresets}
                       nozzleTempPresets={effectiveNozzleTempPresets}
@@ -9590,8 +9672,9 @@ export function PrintersPage() {
                 tempFair: Number(settings.ams_temp_fair) || 35,
               } : undefined}
               timeFormat={settings?.time_format || 'system'}
-              cameraViewMode={settings?.camera_view_mode || 'window'}
+              cameraViewMode={cameraViewMode}
               onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
+              onSelectCameraViewMode={selectCameraViewMode}
               checkPrinterFirmware={settings?.check_printer_firmware !== false}
               dryingPresets={effectiveDryingPresets}
               nozzleTempPresets={effectiveNozzleTempPresets}

+ 5 - 21
frontend/src/pages/SettingsPage.tsx

@@ -1065,7 +1065,6 @@ export function SettingsPage() {
       baseline.ha_token !== localSettings.ha_token ||
       (baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
-      (baseline.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
       (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       resolveEngine(baseline.slice_engine) !== resolveEngine(localSettings.slice_engine) ||
       (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
@@ -1177,7 +1176,6 @@ export function SettingsPage() {
         ha_token: localSettings.ha_token,
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
-        camera_view_mode: localSettings.camera_view_mode,
         preferred_slicer: localSettings.preferred_slicer,
         slice_engine: localSettings.slice_engine,
         open_in_slicer: localSettings.open_in_slicer,
@@ -2110,27 +2108,13 @@ export function SettingsPage() {
               </h2>
             </CardHeader>
             <CardContent className="space-y-3">
-              <div>
-                <label className="block text-sm text-bambu-gray mb-1">
-                  {t('settings.cameraViewMode')}
-                </label>
-                <select
-                  value={localSettings.camera_view_mode ?? 'window'}
-                  onChange={(e) => updateSetting('camera_view_mode', e.target.value as 'window' | 'embedded')}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                >
-                  <option value="window">{t('settings.newWindow')}</option>
-                  <option value="embedded">{t('settings.embeddedOverlay')}</option>
-                </select>
-                <p className="text-xs text-bambu-gray mt-1">
-                  {localSettings.camera_view_mode === 'embedded'
-                    ? t('settings.cameraOverlayDescription')
-                    : t('settings.cameraWindowDescription')}
-                </p>
-              </div>
+              {/* Camera View Mode used to live here. It is now the split
+                  camera button on each printer card, where the choice is made
+                  per stream instead of once for the whole install. The stored
+                  setting stays as the default a fresh browser starts from. */}
 
               {/* External Cameras Section */}
-              <div className="border-t border-bambu-dark-tertiary pt-4 mt-4">
+              <div>
                 <h3 className="text-sm font-medium text-white mb-2">{t('settings.externalCameras')}</h3>
                 <p className="text-xs text-bambu-gray mb-3">
                   {t('settings.externalCamerasDescription')}

+ 82 - 0
frontend/src/utils/camera.ts

@@ -0,0 +1,82 @@
+/**
+ * How a camera stream opens: a separate browser window, or a floating overlay
+ * on the page you are already on.
+ *
+ * This used to be one global switch in Settings > General > Camera. It is now
+ * chosen per click from the camera button's own menu, and the stored setting
+ * survives only as the default a browser starts from.
+ */
+export type CameraViewMode = 'window' | 'embedded';
+
+/** The mode the last camera button click chose, in this browser. */
+const CAMERA_VIEW_MODE_KEY = 'cameraViewMode';
+
+/** Geometry the camera popup was last left at, written by CameraPage. */
+const CAMERA_WINDOW_STATE_KEY = 'cameraWindowState';
+
+export function isCameraViewMode(value: unknown): value is CameraViewMode {
+  return value === 'window' || value === 'embedded';
+}
+
+/**
+ * The local choice, or null if this browser has never made one.
+ *
+ * Null rather than a default so the caller can fall back to the server-side
+ * setting: that is what makes a fresh browser open the same way the last one
+ * did, without the local choice ever being overwritten by it.
+ */
+export function readStoredCameraViewMode(): CameraViewMode | null {
+  try {
+    const saved = localStorage.getItem(CAMERA_VIEW_MODE_KEY);
+    return isCameraViewMode(saved) ? saved : null;
+  } catch {
+    // Private-mode Safari and friends. A camera that opens the default way is
+    // a better outcome than a page that throws on render.
+    return null;
+  }
+}
+
+export function storeCameraViewMode(mode: CameraViewMode): void {
+  try {
+    localStorage.setItem(CAMERA_VIEW_MODE_KEY, mode);
+  } catch {
+    // Choice applies to this session regardless; only persistence is lost.
+  }
+}
+
+/**
+ * Open a printer's camera in its own browser window, reusing whatever size and
+ * position the user last left one at.
+ *
+ * Deliberately not `noopener`: the popup is same-origin and needs `opener` set
+ * 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 {
+  let state: { width?: number; height?: number; left?: number; top?: number } = {
+    width: 640,
+    height: 400,
+  };
+  try {
+    const saved = localStorage.getItem(CAMERA_WINDOW_STATE_KEY);
+    if (saved) {
+      const parsed = JSON.parse(saved) as typeof state;
+      if (parsed && typeof parsed === 'object') state = parsed;
+    }
+  } catch {
+    // Corrupt or unreadable geometry falls back to the defaults above rather
+    // than leaving the camera unopenable.
+  }
+
+  const features = [
+    `width=${state.width ?? 640}`,
+    `height=${state.height ?? 400}`,
+    state.left !== undefined ? `left=${state.left}` : '',
+    state.top !== undefined ? `top=${state.top}` : '',
+    'menubar=no,toolbar=no,location=no,status=no',
+  ]
+    .filter(Boolean)
+    .join(',');
+
+  window.open(`/camera/${printerId}`, `camera-${printerId}`, features);
+}

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-C_NRLB80.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Cxc_iG-w.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-VSpFxsmE.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-61kdXEBh.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-VSpFxsmE.css">
+    <script type="module" crossorigin src="/assets/index-Cxc_iG-w.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C_NRLB80.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов