فهرست منبع

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 2 هفته پیش
والد
کامیت
7354650835

+ 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>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است