فهرست منبع

feat(printers): cam-wall view with on-screen live cap and snapshot fallback (issue #451)

  New view toggle on the Printers page renders a responsive grid of live
  camera tiles instead of printer cards. Reuses the existing /camera/stream
  fan-out so the backend ffmpeg pipeline is unchanged.

  To stay sustainable on the median Pi 4 install, only on-screen tiles run
  live, and only up to a per-user cap (default 4). Other visible tiles
  fall back to periodic /camera/snapshot polling (default 8s). Off-screen
  tiles pause entirely. Tiles POST /camera/stop on unmount and on
  leave-live so the backend transcoder slot is released the same way
  EmbeddedCameraViewer does it.

  CameraTile is a 3-mode leaf (live / snapshot / paused) with a single
  <img> and an onError no-signal fallback. CameraWall is the scheduler:
  IntersectionObserver tracks visibility, a stable walker over the sorted
  printer list assigns live slots first-N-visible to avoid LRU churn. Same
  ['printerStatus', id] React Query cache the cards already populate, so
  flipping between Cards and Cam Wall is instant.

  Tile click honours the existing Settings camera_view_mode preference
  (window vs embedded). Both wall settings are per-user localStorage
  (camWallMaxLive, camWallSnapshotSec) — a Pi 4 user and a NUC user want
  different caps.
maziggy 2 ماه پیش
والد
کامیت
b90dee02ab

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 109 - 0
frontend/src/__tests__/components/CameraTile.test.tsx

@@ -0,0 +1,109 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, screen } from '@testing-library/react';
+import { render } from '../utils';
+import { CameraTile } from '../../components/CameraTile';
+
+describe('CameraTile', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    vi.restoreAllMocks();
+  });
+
+  it('renders the live stream URL in live mode', () => {
+    render(
+      <CameraTile
+        printerId={42}
+        printerName="X1C-Lab"
+        mode="live"
+        snapshotIntervalMs={5000}
+        connected
+      />,
+    );
+    const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+    expect(img.src).toContain('/api/v1/printers/42/camera/stream');
+    expect(img.src).toContain('fps=8');
+  });
+
+  it('renders the snapshot URL and refreshes on the interval', () => {
+    render(
+      <CameraTile
+        printerId={7}
+        printerName="P1S-Garage"
+        mode="snapshot"
+        snapshotIntervalMs={1000}
+        connected
+      />,
+    );
+    const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
+    expect(initial).toContain('/api/v1/printers/7/camera/snapshot');
+
+    act(() => {
+      vi.advanceTimersByTime(1500);
+    });
+    const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
+    expect(refreshed).toContain('/api/v1/printers/7/camera/snapshot');
+    expect(refreshed).not.toBe(initial);
+  });
+
+  it('shows an offline placeholder when not connected', () => {
+    render(
+      <CameraTile
+        printerId={1}
+        printerName="A1-Offline"
+        mode="live"
+        snapshotIntervalMs={5000}
+        connected={false}
+      />,
+    );
+    expect(screen.queryByAltText('A1-Offline')).toBeNull();
+  });
+
+  it('shows the paused placeholder in paused mode', () => {
+    render(
+      <CameraTile
+        printerId={9}
+        printerName="H2D-Booth"
+        mode="paused"
+        snapshotIntervalMs={5000}
+        connected
+      />,
+    );
+    expect(screen.queryByAltText('H2D-Booth')).toBeNull();
+  });
+
+  it('POSTs /camera/stop when leaving live mode', async () => {
+    const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue(
+      new Response(null, { status: 200 }),
+    );
+    const { rerender } = render(
+      <CameraTile
+        printerId={11}
+        printerName="X1C-Stop"
+        mode="live"
+        snapshotIntervalMs={5000}
+        connected
+      />,
+    );
+    fetchMock.mockClear();
+
+    rerender(
+      <CameraTile
+        printerId={11}
+        printerName="X1C-Stop"
+        mode="snapshot"
+        snapshotIntervalMs={5000}
+        connected
+      />,
+    );
+
+    const stopCalls = fetchMock.mock.calls.filter(([url]) =>
+      String(url).includes('/api/v1/printers/11/camera/stop'),
+    );
+    expect(stopCalls.length).toBeGreaterThan(0);
+  });
+});

+ 148 - 0
frontend/src/components/CameraTile.tsx

@@ -0,0 +1,148 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { VideoOff, WifiOff } from 'lucide-react';
+import { getAuthToken, withStreamToken } from '../api/client';
+
+export type CameraTileMode = 'live' | 'snapshot' | 'paused';
+
+interface CameraTileProps {
+  printerId: number;
+  printerName: string;
+  cameraRotation?: number;
+  mode: CameraTileMode;
+  snapshotIntervalMs: number;
+  connected: boolean;
+  onClick?: () => void;
+}
+
+// Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
+// no drag/resize/zoom shell, and snapshot fallback when off-cap. The server
+// still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
+const LIVE_FPS = 8;
+
+export function CameraTile({
+  printerId,
+  printerName,
+  cameraRotation = 0,
+  mode,
+  snapshotIntervalMs,
+  connected,
+  onClick,
+}: CameraTileProps) {
+  const { t } = useTranslation();
+  const [bust, setBust] = useState(0);
+  const [errored, setErrored] = useState(false);
+  const lastModeRef = useRef<CameraTileMode>(mode);
+
+  // Tell the backend to release its MJPEG transcoder when this tile stops
+  // being live — either by unmounting or by transitioning to snapshot/paused.
+  // EmbeddedCameraViewer uses the same /camera/stop with keepalive on unmount.
+  useEffect(() => {
+    const wasLive = lastModeRef.current === 'live';
+    const isLive = mode === 'live';
+    lastModeRef.current = mode;
+    if (wasLive && !isLive) {
+      const headers: Record<string, string> = {};
+      const token = getAuthToken();
+      if (token) headers['Authorization'] = `Bearer ${token}`;
+      fetch(`/api/v1/printers/${printerId}/camera/stop`, {
+        method: 'POST',
+        keepalive: true,
+        headers,
+      }).catch(() => {});
+    }
+    setErrored(false);
+    setBust((b) => b + 1);
+  }, [mode, printerId]);
+
+  useEffect(() => {
+    return () => {
+      if (lastModeRef.current === 'live') {
+        const headers: Record<string, string> = {};
+        const token = getAuthToken();
+        if (token) headers['Authorization'] = `Bearer ${token}`;
+        fetch(`/api/v1/printers/${printerId}/camera/stop`, {
+          method: 'POST',
+          keepalive: true,
+          headers,
+        }).catch(() => {});
+      }
+    };
+  }, [printerId]);
+
+  useEffect(() => {
+    if (mode !== 'snapshot') return;
+    const interval = setInterval(() => setBust((b) => b + 1), snapshotIntervalMs);
+    return () => clearInterval(interval);
+  }, [mode, snapshotIntervalMs]);
+
+  const liveUrl = withStreamToken(
+    `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`,
+  );
+  const snapshotUrl = withStreamToken(
+    `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
+  );
+
+  const handleClick = () => {
+    if (onClick) onClick();
+  };
+
+  const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
+
+  return (
+    <button
+      type="button"
+      onClick={handleClick}
+      className="group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left focus:outline-none focus:ring-2 focus:ring-bambu-green"
+      title={printerName}
+    >
+      {!connected || mode === 'paused' ? (
+        <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/60">
+          {connected ? (
+            <VideoOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
+          ) : (
+            <WifiOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
+          )}
+        </div>
+      ) : errored ? (
+        <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/80 text-bambu-gray">
+          <VideoOff className="h-7 w-7" aria-hidden="true" />
+          <span className="text-xs">{t('printers.camWall.noSignal')}</span>
+        </div>
+      ) : (
+        <img
+          key={`${mode}-${bust}`}
+          src={mode === 'live' ? liveUrl : snapshotUrl}
+          alt={printerName}
+          draggable={false}
+          loading="lazy"
+          className="h-full w-full select-none object-contain"
+          style={{ transform }}
+          onError={() => setErrored(true)}
+        />
+      )}
+
+      {/* Mode indicator */}
+      <span
+        className={`absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
+          mode === 'live'
+            ? 'bg-red-500/80 text-white'
+            : mode === 'snapshot'
+              ? 'bg-amber-500/70 text-black'
+              : 'bg-bambu-dark-tertiary/70 text-bambu-gray'
+        }`}
+      >
+        {mode === 'live'
+          ? t('printers.camWall.live')
+          : mode === 'snapshot'
+            ? t('printers.camWall.snap')
+            : t('printers.camWall.off')}
+      </span>
+
+      {/* Name overlay */}
+      <span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/80 to-transparent px-2 pb-1.5 pt-3 text-xs font-medium text-white">
+        {printerName}
+      </span>
+    </button>
+  );
+}

+ 216 - 0
frontend/src/components/CameraWall.tsx

@@ -0,0 +1,216 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQueries } from '@tanstack/react-query';
+import { Settings as SettingsIcon } from 'lucide-react';
+import { CameraTile, type CameraTileMode } from './CameraTile';
+import { api, type Printer } from '../api/client';
+
+interface CameraWallProps {
+  printers: Printer[];
+  maxLive: number;
+  snapshotIntervalSec: number;
+  onTileClick: (printerId: number, printerName: string) => void;
+  onChangeMaxLive: (next: number) => void;
+  onChangeSnapshotIntervalSec: (next: number) => void;
+}
+
+const MIN_MAX_LIVE = 1;
+const MAX_MAX_LIVE = 16;
+const MIN_SNAPSHOT_SEC = 2;
+const MAX_SNAPSHOT_SEC = 60;
+
+export function CameraWall({
+  printers,
+  maxLive,
+  snapshotIntervalSec,
+  onTileClick,
+  onChangeMaxLive,
+  onChangeSnapshotIntervalSec,
+}: CameraWallProps) {
+  const { t } = useTranslation();
+  const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
+
+  // Reuses the same ['printerStatus', id] cache that each PrinterCard
+  // populates, so flipping between Cards and Cam Wall is instant.
+  const statusQueries = useQueries({
+    queries: printers.map((p) => ({
+      queryKey: ['printerStatus', p.id],
+      queryFn: () => api.getPrinterStatus(p.id),
+      staleTime: 5000,
+    })),
+  });
+  const printerConnected = useMemo(() => {
+    const map = new Map<number, boolean>();
+    printers.forEach((p, i) => {
+      map.set(p.id, statusQueries[i]?.data?.connected ?? false);
+    });
+    return map;
+  }, [printers, statusQueries]);
+  const [visibleIds, setVisibleIds] = useState<Set<number>>(() => new Set());
+  const [showSettings, setShowSettings] = useState(false);
+  const settingsRef = useRef<HTMLDivElement | null>(null);
+
+  useEffect(() => {
+    if (!showSettings) return;
+    const handler = (e: MouseEvent) => {
+      if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
+        setShowSettings(false);
+      }
+    };
+    document.addEventListener('mousedown', handler);
+    return () => document.removeEventListener('mousedown', handler);
+  }, [showSettings]);
+
+  // IntersectionObserver: a tile is "visible" when ≥40% of it is on-screen.
+  // 40% (not 0%) avoids flicker at scroll boundaries where a tile is fractionally
+  // visible — we don't want to spin up a live stream for a 5-pixel sliver.
+  useEffect(() => {
+    const observer = new IntersectionObserver(
+      (entries) => {
+        setVisibleIds((prev) => {
+          const next = new Set(prev);
+          for (const entry of entries) {
+            const id = Number((entry.target as HTMLElement).dataset.printerId);
+            if (!Number.isFinite(id)) continue;
+            if (entry.isIntersecting) next.add(id);
+            else next.delete(id);
+          }
+          return next;
+        });
+      },
+      { threshold: 0.4 },
+    );
+
+    for (const [, el] of tileRefs.current) {
+      if (el) observer.observe(el);
+    }
+    return () => observer.disconnect();
+  }, [printers]);
+
+  // Live slot allocation: visible tiles get live up to `maxLive`, in printer
+  // list order so the assignment is stable. Visible-but-over-cap fall back to
+  // snapshot polling. Off-screen tiles render paused (no network).
+  const modeByPrinter = useMemo(() => {
+    const map = new Map<number, CameraTileMode>();
+    let liveBudget = Math.max(0, maxLive);
+    for (const p of printers) {
+      if (!visibleIds.has(p.id)) {
+        map.set(p.id, 'paused');
+        continue;
+      }
+      if (liveBudget > 0) {
+        map.set(p.id, 'live');
+        liveBudget -= 1;
+      } else {
+        map.set(p.id, 'snapshot');
+      }
+    }
+    return map;
+  }, [printers, visibleIds, maxLive]);
+
+  if (printers.length === 0) {
+    return (
+      <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-6 text-center text-bambu-gray">
+        {t('printers.camWall.noPrinters')}
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-3">
+      <div className="flex items-center justify-between text-xs text-bambu-gray">
+        <span>
+          {t('printers.camWall.summary', {
+            live: Array.from(modeByPrinter.values()).filter((m) => m === 'live').length,
+            snap: Array.from(modeByPrinter.values()).filter((m) => m === 'snapshot').length,
+            total: printers.length,
+          })}
+        </span>
+        <div className="relative" ref={settingsRef}>
+          <button
+            type="button"
+            onClick={() => setShowSettings((v) => !v)}
+            className="flex h-7 items-center gap-1 rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 text-white hover:bg-bambu-dark-tertiary"
+            title={t('printers.camWall.settings.title')}
+          >
+            <SettingsIcon className="h-3.5 w-3.5" />
+            <span>{t('printers.camWall.settings.title')}</span>
+          </button>
+          {showSettings && (
+            <div className="absolute right-0 top-9 z-30 w-72 space-y-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-3 shadow-xl">
+              <label className="block space-y-1">
+                <span className="text-xs font-medium text-white">
+                  {t('printers.camWall.settings.maxLive')}
+                </span>
+                <input
+                  type="number"
+                  min={MIN_MAX_LIVE}
+                  max={MAX_MAX_LIVE}
+                  value={maxLive}
+                  onChange={(e) => {
+                    const n = Math.min(
+                      MAX_MAX_LIVE,
+                      Math.max(MIN_MAX_LIVE, Number(e.target.value) || MIN_MAX_LIVE),
+                    );
+                    onChangeMaxLive(n);
+                  }}
+                  className="w-full rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-sm text-white"
+                />
+                <span className="block text-[11px] text-bambu-gray">
+                  {t('printers.camWall.settings.maxLiveHint')}
+                </span>
+              </label>
+              <label className="block space-y-1">
+                <span className="text-xs font-medium text-white">
+                  {t('printers.camWall.settings.snapshotInterval')}
+                </span>
+                <input
+                  type="number"
+                  min={MIN_SNAPSHOT_SEC}
+                  max={MAX_SNAPSHOT_SEC}
+                  value={snapshotIntervalSec}
+                  onChange={(e) => {
+                    const n = Math.min(
+                      MAX_SNAPSHOT_SEC,
+                      Math.max(MIN_SNAPSHOT_SEC, Number(e.target.value) || MIN_SNAPSHOT_SEC),
+                    );
+                    onChangeSnapshotIntervalSec(n);
+                  }}
+                  className="w-full rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-sm text-white"
+                />
+                <span className="block text-[11px] text-bambu-gray">
+                  {t('printers.camWall.settings.snapshotIntervalHint')}
+                </span>
+              </label>
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+        {printers.map((p) => {
+          const mode = modeByPrinter.get(p.id) ?? 'paused';
+          return (
+            <div
+              key={p.id}
+              ref={(el) => {
+                tileRefs.current.set(p.id, el);
+              }}
+              data-printer-id={p.id}
+            >
+              <CameraTile
+                printerId={p.id}
+                printerName={p.name}
+                cameraRotation={p.camera_rotation}
+                mode={mode}
+                snapshotIntervalMs={snapshotIntervalSec * 1000}
+                connected={printerConnected.get(p.id) ?? false}
+                onClick={() => onTileClick(p.id, p.name)}
+              />
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

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

@@ -193,6 +193,25 @@ export default {
       large: 'Große Karten',
       extraLarge: 'Extra große Karten',
     },
+    pageView: {
+      cards: 'Karten',
+      camWall: 'Kamera-Wand',
+    },
+    camWall: {
+      noPrinters: 'Keine Drucker anzuzeigen',
+      noSignal: 'Kein Signal',
+      live: 'Live',
+      snap: 'Foto',
+      off: 'Aus',
+      summary: '{{live}} live, {{snap}} Schnappschüsse, {{total}} insgesamt',
+      settings: {
+        title: 'Kamera-Wand-Einstellungen',
+        maxLive: 'Max. Live-Streams',
+        maxLiveHint: 'Wie viele Kacheln gleichzeitig live streamen. Andere aktualisieren als Schnappschüsse.',
+        snapshotInterval: 'Schnappschuss-Intervall (Sekunden)',
+        snapshotIntervalHint: 'Wie oft Nicht-Live-Kacheln einen neuen Schnappschuss abrufen.',
+      },
+    },
     // Controls
     hideOffline: 'Offline ausblenden',
     nextAvailable: 'Nächster verfügbar',

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

@@ -193,6 +193,25 @@ export default {
       large: 'Large cards',
       extraLarge: 'Extra large cards',
     },
+    pageView: {
+      cards: 'Cards',
+      camWall: 'Cam wall',
+    },
+    camWall: {
+      noPrinters: 'No printers to show',
+      noSignal: 'No signal',
+      live: 'Live',
+      snap: 'Snap',
+      off: 'Off',
+      summary: '{{live}} live, {{snap}} snapshots, {{total}} total',
+      settings: {
+        title: 'Cam wall settings',
+        maxLive: 'Max live streams',
+        maxLiveHint: 'How many tiles stream live at once. Others refresh as snapshots.',
+        snapshotInterval: 'Snapshot interval (seconds)',
+        snapshotIntervalHint: 'How often non-live tiles fetch a fresh snapshot.',
+      },
+    },
     // Controls
     hideOffline: 'Hide offline',
     nextAvailable: 'Next available',

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

@@ -193,6 +193,25 @@ export default {
       large: 'Tarjetas grandes',
       extraLarge: 'Tarjetas extragrandes',
     },
+    pageView: {
+      cards: 'Tarjetas',
+      camWall: 'Muro de cámaras',
+    },
+    camWall: {
+      noPrinters: 'No hay impresoras que mostrar',
+      noSignal: 'Sin señal',
+      live: 'En vivo',
+      snap: 'Foto',
+      off: 'Inactivo',
+      summary: '{{live}} en vivo, {{snap}} fotos, {{total}} en total',
+      settings: {
+        title: 'Ajustes del muro de cámaras',
+        maxLive: 'Máx. transmisiones en vivo',
+        maxLiveHint: 'Cuántos mosaicos transmiten en vivo a la vez. Los demás se actualizan como fotos.',
+        snapshotInterval: 'Intervalo de fotos (segundos)',
+        snapshotIntervalHint: 'Con qué frecuencia los mosaicos no en vivo obtienen una nueva foto.',
+      },
+    },
     // Controls
     hideOffline: 'Ocultar desconectadas',
     nextAvailable: 'Próxima disponible',

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

@@ -193,6 +193,25 @@ export default {
       large: 'Grandes cartes',
       extraLarge: 'Très grandes cartes',
     },
+    pageView: {
+      cards: 'Cartes',
+      camWall: 'Mur de caméras',
+    },
+    camWall: {
+      noPrinters: 'Aucune imprimante à afficher',
+      noSignal: 'Aucun signal',
+      live: 'En direct',
+      snap: 'Photo',
+      off: 'Arrêt',
+      summary: '{{live}} en direct, {{snap}} captures, {{total}} au total',
+      settings: {
+        title: 'Paramètres du mur de caméras',
+        maxLive: 'Flux en direct max.',
+        maxLiveHint: 'Combien de vignettes diffusent en direct à la fois. Les autres se rafraîchissent en captures.',
+        snapshotInterval: 'Intervalle de capture (secondes)',
+        snapshotIntervalHint: 'À quelle fréquence les vignettes hors direct récupèrent une nouvelle capture.',
+      },
+    },
     // Controls
     hideOffline: 'Masquer hors ligne',
     nextAvailable: 'Prochaine disponible',

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

@@ -193,6 +193,25 @@ export default {
       large: 'Schede grandi',
       extraLarge: 'Schede extra grandi',
     },
+    pageView: {
+      cards: 'Schede',
+      camWall: 'Muro telecamere',
+    },
+    camWall: {
+      noPrinters: 'Nessuna stampante da mostrare',
+      noSignal: 'Nessun segnale',
+      live: 'Live',
+      snap: 'Foto',
+      off: 'Spento',
+      summary: '{{live}} live, {{snap}} foto, {{total}} totali',
+      settings: {
+        title: 'Impostazioni muro telecamere',
+        maxLive: 'Max stream live',
+        maxLiveHint: 'Quante tessere trasmettono in live contemporaneamente. Le altre si aggiornano come foto.',
+        snapshotInterval: 'Intervallo foto (secondi)',
+        snapshotIntervalHint: 'Con quale frequenza le tessere non live scaricano una nuova foto.',
+      },
+    },
     // Controls
     hideOffline: 'Nascondi offline',
     nextAvailable: 'Prossima disponibile',

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

@@ -192,6 +192,25 @@ export default {
       large: '大',
       extraLarge: '特大',
     },
+    pageView: {
+      cards: 'カード',
+      camWall: 'カメラウォール',
+    },
+    camWall: {
+      noPrinters: '表示するプリンターがありません',
+      noSignal: '信号なし',
+      live: 'ライブ',
+      snap: 'スナップ',
+      off: 'オフ',
+      summary: 'ライブ {{live}}件、スナップ {{snap}}件、合計 {{total}}件',
+      settings: {
+        title: 'カメラウォール設定',
+        maxLive: '最大ライブ配信数',
+        maxLiveHint: '同時にライブ配信するタイル数。残りはスナップショットとして更新されます。',
+        snapshotInterval: 'スナップショット間隔(秒)',
+        snapshotIntervalHint: '非ライブのタイルが新しいスナップショットを取得する頻度。',
+      },
+    },
     // Controls
     hideOffline: 'オフラインを非表示',
     nextAvailable: '次に完了',

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

@@ -180,6 +180,25 @@ export default {
       large: '큰 카드',
       extraLarge: '아주 큰 카드'
     },
+    pageView: {
+      cards: '카드',
+      camWall: '카메라 월'
+    },
+    camWall: {
+      noPrinters: '표시할 프린터가 없습니다',
+      noSignal: '신호 없음',
+      live: '라이브',
+      snap: '스냅',
+      off: '꺼짐',
+      summary: '라이브 {{live}}개, 스냅 {{snap}}개, 총 {{total}}개',
+      settings: {
+        title: '카메라 월 설정',
+        maxLive: '최대 라이브 스트림',
+        maxLiveHint: '동시에 라이브 스트리밍할 타일 수. 나머지는 스냅샷으로 갱신됩니다.',
+        snapshotInterval: '스냅샷 간격(초)',
+        snapshotIntervalHint: '비라이브 타일이 새 스냅샷을 가져오는 주기.'
+      }
+    },
     hideOffline: '오프라인 숨기기',
     nextAvailable: '다음 가용',
     powerOn: '전원 켜기',

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

@@ -193,6 +193,25 @@ export default {
       large: 'Cartões grandes',
       extraLarge: 'Cartões extra grandes',
     },
+    pageView: {
+      cards: 'Cartões',
+      camWall: 'Mural de câmeras',
+    },
+    camWall: {
+      noPrinters: 'Nenhuma impressora para exibir',
+      noSignal: 'Sem sinal',
+      live: 'Ao vivo',
+      snap: 'Foto',
+      off: 'Desligado',
+      summary: '{{live}} ao vivo, {{snap}} fotos, {{total}} no total',
+      settings: {
+        title: 'Configurações do mural de câmeras',
+        maxLive: 'Máx. transmissões ao vivo',
+        maxLiveHint: 'Quantos blocos transmitem ao vivo simultaneamente. Os demais atualizam como fotos.',
+        snapshotInterval: 'Intervalo de foto (segundos)',
+        snapshotIntervalHint: 'Com que frequência os blocos não ao vivo buscam uma nova foto.',
+      },
+    },
     // Controls
     hideOffline: 'Ocultar offline',
     nextAvailable: 'Próximo disponível',

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

@@ -193,6 +193,25 @@ export default {
       large: 'Büyük kartlar',
       extraLarge: 'Çok büyük kartlar',
     },
+    pageView: {
+      cards: 'Kartlar',
+      camWall: 'Kamera duvarı',
+    },
+    camWall: {
+      noPrinters: 'Gösterilecek yazıcı yok',
+      noSignal: 'Sinyal yok',
+      live: 'Canlı',
+      snap: 'Foto',
+      off: 'Kapalı',
+      summary: '{{live}} canlı, {{snap}} fotoğraf, toplam {{total}}',
+      settings: {
+        title: 'Kamera duvarı ayarları',
+        maxLive: 'Maks. canlı yayın',
+        maxLiveHint: 'Aynı anda kaç döşemenin canlı yayın yaptığı. Diğerleri foto olarak yenilenir.',
+        snapshotInterval: 'Foto aralığı (saniye)',
+        snapshotIntervalHint: 'Canlı olmayan döşemelerin ne sıklıkla yeni bir foto aldığı.',
+      },
+    },
     // Kontroller
     hideOffline: 'Çevrimdışı olanları gizle',
     nextAvailable: 'Sıradaki müsait',

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

@@ -193,6 +193,25 @@ export default {
       large: '大卡片',
       extraLarge: '超大卡片',
     },
+    pageView: {
+      cards: '卡片',
+      camWall: '摄像头墙',
+    },
+    camWall: {
+      noPrinters: '没有可显示的打印机',
+      noSignal: '无信号',
+      live: '直播',
+      snap: '快照',
+      off: '关闭',
+      summary: '直播 {{live}} 个,快照 {{snap}} 个,共 {{total}} 个',
+      settings: {
+        title: '摄像头墙设置',
+        maxLive: '最大直播数',
+        maxLiveHint: '同时直播的画面数量。其他画面以快照刷新。',
+        snapshotInterval: '快照刷新间隔(秒)',
+        snapshotIntervalHint: '非直播画面获取新快照的频率。',
+      },
+    },
     // Controls
     hideOffline: '隐藏离线',
     nextAvailable: '下一个可用',

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

@@ -193,6 +193,25 @@ export default {
       large: '大卡片',
       extraLarge: '超大卡片',
     },
+    pageView: {
+      cards: '卡片',
+      camWall: '攝影機牆',
+    },
+    camWall: {
+      noPrinters: '沒有可顯示的印表機',
+      noSignal: '無訊號',
+      live: '直播',
+      snap: '快照',
+      off: '關閉',
+      summary: '直播 {{live}} 個,快照 {{snap}} 個,共 {{total}} 個',
+      settings: {
+        title: '攝影機牆設定',
+        maxLive: '最大直播數',
+        maxLiveHint: '同時直播的畫面數量。其他畫面以快照重新整理。',
+        snapshotInterval: '快照重新整理間隔(秒)',
+        snapshotIntervalHint: '非直播畫面取得新快照的頻率。',
+      },
+    },
     // Controls
     hideOffline: '隱藏離線',
     nextAvailable: '下一個可用',

+ 84 - 1
frontend/src/pages/PrintersPage.tsx

@@ -82,6 +82,8 @@ import {
   SlidersHorizontal,
   Stethoscope,
   LineChart as LineChartIcon,
+  LayoutGrid,
+  MonitorPlay,
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
@@ -94,6 +96,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
+import { CameraWall } from '../components/CameraWall';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
@@ -7604,6 +7607,20 @@ export function PrintersPage() {
     const saved = localStorage.getItem('printerCardSize');
     return saved ? parseInt(saved, 10) : 2; // Default to medium
   });
+  // Page view: 'cards' = printer cards (default), 'camwall' = grid of live camera tiles
+  const [pageView, setPageView] = useState<'cards' | 'camwall'>(() => {
+    return localStorage.getItem('printerPageView') === 'camwall' ? 'camwall' : 'cards';
+  });
+  // Cam-wall settings — per-user, no backend write (a Pi 4 install caps the
+  // live count lower than a NUC; default 4 is the documented Pi 4 ceiling).
+  const [camWallMaxLive, setCamWallMaxLive] = useState<number>(() => {
+    const saved = parseInt(localStorage.getItem('camWallMaxLive') || '', 10);
+    return Number.isFinite(saved) && saved > 0 ? saved : 4;
+  });
+  const [camWallSnapshotSec, setCamWallSnapshotSec] = useState<number>(() => {
+    const saved = parseInt(localStorage.getItem('camWallSnapshotSec') || '', 10);
+    return Number.isFinite(saved) && saved > 0 ? saved : 8;
+  });
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const [compactDrilldownPrinterId, setCompactDrilldownPrinterId] = useState<number | null>(null);
@@ -8333,8 +8350,43 @@ export function PrintersPage() {
         </button>
       </div>
 
-      {/* Card size selector */}
+      {/* Page view toggle: Cards / Cam Wall */}
       <div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${inMenu ? 'w-full' : ''}`}>
+        <button
+          type="button"
+          onClick={() => {
+            setPageView('cards');
+            localStorage.setItem('printerPageView', 'cards');
+          }}
+          className={`flex h-full items-center gap-1 rounded-l-lg px-2 text-xs font-medium transition-colors ${inMenu ? 'flex-1 justify-center' : ''} ${
+            pageView === 'cards' ? 'bg-bambu-green text-white' : 'text-white hover:bg-bambu-dark-tertiary'
+          }`}
+          title={t('printers.pageView.cards')}
+          aria-pressed={pageView === 'cards'}
+        >
+          <LayoutGrid className="w-3.5 h-3.5" />
+          {inMenu && <span>{t('printers.pageView.cards')}</span>}
+        </button>
+        <button
+          type="button"
+          onClick={() => {
+            setPageView('camwall');
+            localStorage.setItem('printerPageView', 'camwall');
+          }}
+          className={`flex h-full items-center gap-1 rounded-r-lg px-2 text-xs font-medium transition-colors ${inMenu ? 'flex-1 justify-center' : ''} ${
+            pageView === 'camwall' ? 'bg-bambu-green text-white' : 'text-white hover:bg-bambu-dark-tertiary'
+          }`}
+          title={t('printers.pageView.camWall')}
+          aria-pressed={pageView === 'camwall'}
+          disabled={!hasPermission('camera:view')}
+        >
+          <MonitorPlay className="w-3.5 h-3.5" />
+          {inMenu && <span>{t('printers.pageView.camWall')}</span>}
+        </button>
+      </div>
+
+      {/* Card size selector */}
+      <div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${pageView === 'camwall' ? 'opacity-40 pointer-events-none' : ''} ${inMenu ? 'w-full' : ''}`}>
         {cardSizeLabels.map((label, index) => {
           const size = index + 1;
           const isSelected = cardSize === size;
@@ -8538,6 +8590,37 @@ export function PrintersPage() {
             <p className="text-bambu-gray">{t('printers.noSearchResults')}</p>
           </CardContent>
         </Card>
+      ) : pageView === 'camwall' ? (
+        <CameraWall
+          printers={sortedPrinters}
+          maxLive={camWallMaxLive}
+          snapshotIntervalSec={camWallSnapshotSec}
+          onTileClick={(id, name) => {
+            const cameraMode = settings?.camera_view_mode || 'window';
+            if (cameraMode === '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);
+            }
+          }}
+          onChangeMaxLive={(next) => {
+            setCamWallMaxLive(next);
+            localStorage.setItem('camWallMaxLive', String(next));
+          }}
+          onChangeSnapshotIntervalSec={(next) => {
+            setCamWallSnapshotSec(next);
+            localStorage.setItem('camWallSnapshotSec', String(next));
+          }}
+        />
       ) : groupedPrinters ? (
         /* Grouped view (location, status, or model) */
         <div className="space-y-6">

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/index-CfaUjcJN.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
static/assets/index-DIWYFok8.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-DMYFpZ9c.js


+ 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-BX7ZbFL-.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CfaUjcJN.css">
+    <script type="module" crossorigin src="/assets/index-DMYFpZ9c.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DIWYFok8.css">
   </head>
   <body>
     <div id="root"></div>

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