Jelajahi Sumber

fix(printers): cam wall — offline tile chip + don't kill shared
streams when one viewer closes

1) Offline tiles now show OFF (not LIVE)
CameraWall.modeByPrinter assigned 'live' to any visible printer
without considering status.connected, so a disconnected X1C wasted
a live-budget slot AND rendered the red LIVE chip on top of the
WifiOff placeholder. Disconnected printers now map to 'paused' and
don't decrement liveBudget — the existing WifiOff + Off chip
rendering takes over.

2) /camera/stop no longer kills other viewers' streams
The cam-wall tile, EmbeddedCameraViewer, and the /camera/:id popup
all subscribe to the same fan-out broadcaster for a printer.
/camera/stop used to unconditionally shutdown_broadcaster() + kill
every ffmpeg process for the printer, so closing the embedded viewer
while the cam-wall tile of the same printer was live force-killed
the source the tile was pulling from — the tile's <img> errored.

New get_subscriber_count(key) accessor in camera_fanout.py exposes
the broadcaster's subscriber list length. /camera/stop now reads
that first; when >= 1 subscriber is still attached, return
{stopped: 0, skipped: true} and leave the broadcaster + ffmpeg
processes alone. The leaving viewer's HTTP teardown still runs the
natural iter_subscriber.finally -> unsubscribe path, so its slot is
released; the broadcaster keeps serving the other viewers. Single-
viewer close still hits the immediate force-teardown (count is 0).

maziggy 2 bulan lalu
induk
melakukan
510005f043

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 25 - 5
backend/app/api/routes/camera.py

@@ -35,6 +35,7 @@ from backend.app.services.camera import (
 from backend.app.services.camera_fanout import (
     MjpegBroadcaster,
     get_or_create_broadcaster,
+    get_subscriber_count,
     iter_subscriber,
     shutdown_broadcaster,
 )
@@ -771,17 +772,36 @@ async def stop_camera_stream(
     printer_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
-    """Stop all active camera streams for a printer.
-
-    This can be called by the frontend when the camera window is closed.
-    Accepts both GET and POST (POST for sendBeacon compatibility).
+    """Stop active camera streams for a printer.
+
+    Called by the frontend on viewer unmount (cam-wall tile, embedded viewer,
+    popup window). Accepts both GET and POST (POST for sendBeacon compatibility).
+
+    Reference-count guard: every viewer of a printer subscribes to the same
+    fan-out broadcaster, so a force-shutdown triggered by ONE leaving viewer
+    used to kill the others' streams (cam-wall tile froze when a user opened
+    then closed the embedded viewer). If any subscriber is still attached,
+    skip the force-teardown — the broadcaster's natural grace-shutdown (5 s
+    after subscribers drop to 0) handles cleanup when the leaving viewer's
+    HTTP connection actually closes.
     """
+    broadcaster_key = f"printer-{printer_id}"
+    remaining_subscribers = get_subscriber_count(broadcaster_key)
+    if remaining_subscribers >= 1:
+        logger.info(
+            "Skipping force-shutdown for printer %s: %d subscriber(s) still attached; "
+            "natural cleanup will tear down when last viewer disconnects",
+            printer_id,
+            remaining_subscribers,
+        )
+        return {"stopped": 0, "skipped": True}
+
     stopped = 0
 
     # Tear down the fan-out broadcaster first (#1089). This cleanly notifies
     # all subscribed viewers and asks the upstream generator to stop
     # reconnecting before we fall back to forcefully killing the process below.
-    if await shutdown_broadcaster(f"printer-{printer_id}"):
+    if await shutdown_broadcaster(broadcaster_key):
         logger.info("Shut down camera fan-out broadcaster for printer %s", printer_id)
 
     # Stop ffmpeg/RTSP streams

+ 14 - 0
backend/app/services/camera_fanout.py

@@ -236,6 +236,20 @@ def active_broadcaster_keys() -> list[str]:
     return [k for k, bc in _broadcasters.items() if not bc.stopped]
 
 
+def get_subscriber_count(key: str) -> int:
+    """Return the number of live subscribers attached to ``key``, or 0.
+
+    Used by ``/camera/stop`` to decide whether to force-shutdown the broadcaster
+    or defer to natural cleanup. Other viewers (cam-wall tile, embedded viewer,
+    popup window) all subscribe to the same broadcaster, so a force-shutdown
+    triggered by one leaving viewer would kill the others' streams.
+    """
+    bc = _broadcasters.get(key)
+    if bc is None or bc.stopped:
+        return 0
+    return bc.subscriber_count
+
+
 # ---------------------------------------------------------------------------
 # AsyncGenerator helper — turns a subscriber queue into an async generator
 # that yields MJPEG chunks until the upstream signals it's gone.

+ 33 - 0
backend/tests/integration/test_camera_api.py

@@ -147,6 +147,39 @@ class TestCameraAPI:
         assert response.status_code == 200
         mock_shutdown.assert_awaited_once_with(f"printer-{printer.id}")
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_camera_stream_skips_shutdown_when_subscribers_remain(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Reference-count guard: when other viewers are still subscribed to the
+        broadcaster, /camera/stop must NOT force-shutdown — otherwise closing
+        the embedded viewer kills the cam-wall tile of the same printer.
+        Natural cleanup tears it down when the last HTTP connection closes.
+        """
+        printer = await printer_factory()
+
+        mock_shutdown = AsyncMock(return_value=True)
+        mock_process = MagicMock()
+        mock_process.returncode = None
+        mock_process.pid = 88888
+        mock_process.terminate = MagicMock()
+        mock_process.wait = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.camera.get_subscriber_count", return_value=2),
+            patch("backend.app.api.routes.camera.shutdown_broadcaster", mock_shutdown),
+            patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc": mock_process}),
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["stopped"] == 0
+        assert result.get("skipped") is True
+        mock_shutdown.assert_not_awaited()
+        mock_process.terminate.assert_not_called()
+
     # ========================================================================
     # Camera Test Endpoint
     # ========================================================================

+ 2 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -207,6 +207,7 @@ const FR_COGNATES = [
   'Cancelling upload...', 'Backup in progress...', 'Searching directory...',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Navigation', 'Budget', 'Commit', 'Designer',
+  'Compact',  // cam-wall status overlay mode — same word in French
   'ntfy, Pushover, Discord, etc.',
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
@@ -237,6 +238,7 @@ const IT_COGNATES = [
   'Hex: #{{hex}}',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Designer',
+  'Off',  // cam-wall status overlay mode — common loanword in Italian UI
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
 ];
 

+ 106 - 6
frontend/src/components/CameraTile.tsx

@@ -1,9 +1,11 @@
 import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { VideoOff, WifiOff } from 'lucide-react';
+import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react';
 import { getAuthToken, withStreamToken } from '../api/client';
+import { formatDuration } from '../utils/date';
 
 export type CameraTileMode = 'live' | 'snapshot' | 'paused';
+export type CameraTileStatusMode = 'off' | 'compact' | 'full';
 
 interface CameraTileProps {
   printerId: number;
@@ -13,6 +15,16 @@ interface CameraTileProps {
   snapshotIntervalMs: number;
   connected: boolean;
   onClick?: () => void;
+  // Optional status overlay — wired by CameraWall from the shared
+  // ['printerStatus', id] query. All optional so existing tests don't break.
+  statusMode?: CameraTileStatusMode;
+  printerState?: string | null;
+  progress?: number | null;
+  remainingMin?: number | null;
+  layerNum?: number | null;
+  totalLayers?: number | null;
+  printName?: string | null;
+  hmsErrorCount?: number;
 }
 
 // Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
@@ -20,6 +32,31 @@ interface CameraTileProps {
 // still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
 const LIVE_FPS = 8;
 
+type StatusBucket = 'printing' | 'paused' | 'finished' | 'error' | 'idle';
+
+function classifyState(state: string | null | undefined, hmsErrorCount: number): StatusBucket {
+  if (hmsErrorCount > 0) return 'error';
+  switch (state) {
+    case 'RUNNING':
+      return 'printing';
+    case 'PAUSE':
+      return 'paused';
+    case 'FINISH':
+    case 'FAILED':
+      return 'finished';
+    default:
+      return 'idle';
+  }
+}
+
+const BUCKET_CHIP_CLASS: Record<StatusBucket, string> = {
+  printing: 'bg-bambu-green/85 text-black',
+  paused: 'bg-amber-500/85 text-black',
+  finished: 'bg-sky-500/80 text-white',
+  error: 'bg-red-500/85 text-white',
+  idle: 'bg-bambu-dark-tertiary/80 text-bambu-gray',
+};
+
 export function CameraTile({
   printerId,
   printerName,
@@ -28,6 +65,14 @@ export function CameraTile({
   snapshotIntervalMs,
   connected,
   onClick,
+  statusMode = 'off',
+  printerState = null,
+  progress = null,
+  remainingMin = null,
+  layerNum = null,
+  totalLayers = null,
+  printName = null,
+  hmsErrorCount = 0,
 }: CameraTileProps) {
   const { t } = useTranslation();
   const [bust, setBust] = useState(0);
@@ -89,6 +134,17 @@ export function CameraTile({
 
   const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
 
+  const bucket = classifyState(printerState, hmsErrorCount);
+  // Hide chip for idle to keep cold walls clean; always show when something
+  // is happening (printing/paused/finished/error).
+  const showChip = connected && statusMode !== 'off' && bucket !== 'idle';
+  const isPrintingOrPaused = bucket === 'printing' || bucket === 'paused';
+  const showInfoStrip = connected && statusMode === 'full' && isPrintingOrPaused;
+  const fileLabel = printName ?? null;
+  const progressPct = progress != null ? Math.round(progress) : null;
+  const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0;
+  const hasRemaining = remainingMin != null && remainingMin > 0;
+
   return (
     <button
       type="button"
@@ -122,7 +178,22 @@ export function CameraTile({
         />
       )}
 
-      {/* Mode indicator */}
+      {/* Status chip (top-left) */}
+      {showChip && (
+        <span
+          className={`absolute left-2 top-2 flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${BUCKET_CHIP_CLASS[bucket]}`}
+        >
+          {hmsErrorCount > 0 && (
+            <AlertTriangle
+              className="h-3 w-3"
+              aria-hidden="true"
+            />
+          )}
+          <span>{t(`printers.status.${bucket}`)}</span>
+        </span>
+      )}
+
+      {/* Mode indicator (top-right) */}
       <span
         className={`absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
           mode === 'live'
@@ -139,10 +210,39 @@ export function CameraTile({
             : 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>
+      {/* Bottom overlay: name + (when full) print info */}
+      <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/55 to-transparent px-2 pb-1.5 pt-3 text-white">
+        {showInfoStrip && (
+          <div className="mb-0.5 space-y-0.5 text-[11px] leading-tight text-white/90">
+            {fileLabel && (
+              <div className="truncate" title={fileLabel}>
+                {fileLabel}
+              </div>
+            )}
+            <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-bambu-gray">
+              {progressPct != null && (
+                <span className="font-semibold text-white">{progressPct}%</span>
+              )}
+              {hasLayers && (
+                <span>
+                  {t('printers.camWall.layer', {
+                    cur: layerNum,
+                    total: totalLayers,
+                  })}
+                </span>
+              )}
+              {hasRemaining && (
+                <span>
+                  {t('printers.camWall.timeLeft', {
+                    time: formatDuration((remainingMin ?? 0) * 60),
+                  })}
+                </span>
+              )}
+            </div>
+          </div>
+        )}
+        <span className="block truncate text-xs font-medium">{printerName}</span>
+      </div>
     </button>
   );
 }

+ 62 - 9
frontend/src/components/CameraWall.tsx

@@ -2,30 +2,36 @@ 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';
+import { CameraTile, type CameraTileMode, type CameraTileStatusMode } from './CameraTile';
+import { filterKnownHMSErrors } from './HMSErrorModal';
+import { api, type Printer, type PrinterStatus } from '../api/client';
 
 interface CameraWallProps {
   printers: Printer[];
   maxLive: number;
   snapshotIntervalSec: number;
+  statusMode: CameraTileStatusMode;
   onTileClick: (printerId: number, printerName: string) => void;
   onChangeMaxLive: (next: number) => void;
   onChangeSnapshotIntervalSec: (next: number) => void;
+  onChangeStatusMode: (next: CameraTileStatusMode) => void;
 }
 
 const MIN_MAX_LIVE = 1;
 const MAX_MAX_LIVE = 16;
 const MIN_SNAPSHOT_SEC = 2;
 const MAX_SNAPSHOT_SEC = 60;
+const STATUS_MODES: CameraTileStatusMode[] = ['off', 'compact', 'full'];
 
 export function CameraWall({
   printers,
   maxLive,
   snapshotIntervalSec,
+  statusMode,
   onTileClick,
   onChangeMaxLive,
   onChangeSnapshotIntervalSec,
+  onChangeStatusMode,
 }: CameraWallProps) {
   const { t } = useTranslation();
   const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
@@ -39,10 +45,10 @@ export function CameraWall({
       staleTime: 5000,
     })),
   });
-  const printerConnected = useMemo(() => {
-    const map = new Map<number, boolean>();
+  const statusByPrinter = useMemo(() => {
+    const map = new Map<number, PrinterStatus | undefined>();
     printers.forEach((p, i) => {
-      map.set(p.id, statusQueries[i]?.data?.connected ?? false);
+      map.set(p.id, statusQueries[i]?.data);
     });
     return map;
   }, [printers, statusQueries]);
@@ -89,12 +95,15 @@ export function CameraWall({
 
   // 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).
+  // snapshot polling. Off-screen tiles render paused (no network). Disconnected
+  // printers also render paused regardless of visibility — there's nothing to
+  // stream and burning a live-budget slot on them would starve a working tile.
   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)) {
+      const connected = statusByPrinter.get(p.id)?.connected ?? false;
+      if (!visibleIds.has(p.id) || !connected) {
         map.set(p.id, 'paused');
         continue;
       }
@@ -106,7 +115,7 @@ export function CameraWall({
       }
     }
     return map;
-  }, [printers, visibleIds, maxLive]);
+  }, [printers, visibleIds, maxLive, statusByPrinter]);
 
   if (printers.length === 0) {
     return (
@@ -182,6 +191,36 @@ export function CameraWall({
                   {t('printers.camWall.settings.snapshotIntervalHint')}
                 </span>
               </label>
+              <div className="space-y-1">
+                <span className="block text-xs font-medium text-white">
+                  {t('printers.camWall.settings.statusOverlay')}
+                </span>
+                <div
+                  role="radiogroup"
+                  aria-label={t('printers.camWall.settings.statusOverlay')}
+                  className="flex overflow-hidden rounded-md border border-bambu-dark-tertiary"
+                >
+                  {STATUS_MODES.map((m) => (
+                    <button
+                      key={m}
+                      type="button"
+                      role="radio"
+                      aria-checked={statusMode === m}
+                      onClick={() => onChangeStatusMode(m)}
+                      className={`flex-1 px-2 py-1 text-xs ${
+                        statusMode === m
+                          ? 'bg-bambu-green text-black font-semibold'
+                          : 'bg-bambu-dark text-white hover:bg-bambu-dark-tertiary'
+                      }`}
+                    >
+                      {t(`printers.camWall.statusMode.${m}`)}
+                    </button>
+                  ))}
+                </div>
+                <span className="block text-[11px] text-bambu-gray">
+                  {t('printers.camWall.settings.statusOverlayHint')}
+                </span>
+              </div>
             </div>
           )}
         </div>
@@ -204,7 +243,21 @@ export function CameraWall({
                 cameraRotation={p.camera_rotation}
                 mode={mode}
                 snapshotIntervalMs={snapshotIntervalSec * 1000}
-                connected={printerConnected.get(p.id) ?? false}
+                connected={statusByPrinter.get(p.id)?.connected ?? false}
+                statusMode={statusMode}
+                printerState={statusByPrinter.get(p.id)?.state ?? null}
+                progress={statusByPrinter.get(p.id)?.progress ?? null}
+                remainingMin={statusByPrinter.get(p.id)?.remaining_time ?? null}
+                layerNum={statusByPrinter.get(p.id)?.layer_num ?? null}
+                totalLayers={statusByPrinter.get(p.id)?.total_layers ?? null}
+                printName={
+                  statusByPrinter.get(p.id)?.subtask_name ??
+                  statusByPrinter.get(p.id)?.gcode_file ??
+                  null
+                }
+                hmsErrorCount={
+                  filterKnownHMSErrors(statusByPrinter.get(p.id)?.hms_errors ?? []).length
+                }
                 onClick={() => onTileClick(p.id, p.name)}
               />
             </div>

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

@@ -204,12 +204,21 @@ export default {
       snap: 'Foto',
       off: 'Aus',
       summary: '{{live}} live, {{snap}} Schnappschüsse, {{total}} insgesamt',
+      layer: 'Schicht {{cur}}/{{total}}',
+      timeLeft: 'noch {{time}}',
+      statusMode: {
+        off: 'Aus',
+        compact: 'Kompakt',
+        full: 'Voll',
+      },
       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.',
+        statusOverlay: 'Status-Overlay',
+        statusOverlayHint: 'Kompakt: nur Status-Plakette. Voll: + Fortschritt, Schicht, Restzeit.',
       },
     },
     // Controls

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

@@ -204,12 +204,21 @@ export default {
       snap: 'Snap',
       off: 'Off',
       summary: '{{live}} live, {{snap}} snapshots, {{total}} total',
+      layer: 'Layer {{cur}}/{{total}}',
+      timeLeft: '{{time}} left',
+      statusMode: {
+        off: 'Off',
+        compact: 'Compact',
+        full: 'Full',
+      },
       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.',
+        statusOverlay: 'Status overlay',
+        statusOverlayHint: 'Compact: state badge only. Full: + progress, layer, time left.',
       },
     },
     // Controls

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

@@ -204,12 +204,21 @@ export default {
       snap: 'Foto',
       off: 'Inactivo',
       summary: '{{live}} en vivo, {{snap}} fotos, {{total}} en total',
+      layer: 'Capa {{cur}}/{{total}}',
+      timeLeft: 'quedan {{time}}',
+      statusMode: {
+        off: 'Apagado',
+        compact: 'Compacto',
+        full: 'Completo',
+      },
       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.',
+        statusOverlay: 'Superposición de estado',
+        statusOverlayHint: 'Compacto: solo insignia de estado. Completo: + progreso, capa, tiempo restante.',
       },
     },
     // Controls

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

@@ -204,12 +204,21 @@ export default {
       snap: 'Photo',
       off: 'Arrêt',
       summary: '{{live}} en direct, {{snap}} captures, {{total}} au total',
+      layer: 'Couche {{cur}}/{{total}}',
+      timeLeft: '{{time}} restantes',
+      statusMode: {
+        off: 'Arrêt',
+        compact: 'Compact',
+        full: 'Complet',
+      },
       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.',
+        statusOverlay: 'Overlay de statut',
+        statusOverlayHint: 'Compact : badge de statut seul. Complet : + progression, couche, temps restant.',
       },
     },
     // Controls

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

@@ -204,12 +204,21 @@ export default {
       snap: 'Foto',
       off: 'Spento',
       summary: '{{live}} live, {{snap}} foto, {{total}} totali',
+      layer: 'Strato {{cur}}/{{total}}',
+      timeLeft: '{{time}} rimanenti',
+      statusMode: {
+        off: 'Off',
+        compact: 'Compatto',
+        full: 'Completo',
+      },
       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.',
+        statusOverlay: 'Overlay di stato',
+        statusOverlayHint: 'Compatto: solo badge di stato. Completo: + avanzamento, strato, tempo residuo.',
       },
     },
     // Controls

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

@@ -203,12 +203,21 @@ export default {
       snap: 'スナップ',
       off: 'オフ',
       summary: 'ライブ {{live}}件、スナップ {{snap}}件、合計 {{total}}件',
+      layer: 'レイヤー {{cur}}/{{total}}',
+      timeLeft: '残り {{time}}',
+      statusMode: {
+        off: 'オフ',
+        compact: 'コンパクト',
+        full: 'フル',
+      },
       settings: {
         title: 'カメラウォール設定',
         maxLive: '最大ライブ配信数',
         maxLiveHint: '同時にライブ配信するタイル数。残りはスナップショットとして更新されます。',
         snapshotInterval: 'スナップショット間隔(秒)',
         snapshotIntervalHint: '非ライブのタイルが新しいスナップショットを取得する頻度。',
+        statusOverlay: 'ステータス表示',
+        statusOverlayHint: 'コンパクト:状態バッジのみ。フル:+進捗・レイヤー・残り時間。',
       },
     },
     // Controls

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

@@ -191,12 +191,21 @@ export default {
       snap: '스냅',
       off: '꺼짐',
       summary: '라이브 {{live}}개, 스냅 {{snap}}개, 총 {{total}}개',
+      layer: '레이어 {{cur}}/{{total}}',
+      timeLeft: '{{time}} 남음',
+      statusMode: {
+        off: '꺼짐',
+        compact: '간단',
+        full: '전체'
+      },
       settings: {
         title: '카메라 월 설정',
         maxLive: '최대 라이브 스트림',
         maxLiveHint: '동시에 라이브 스트리밍할 타일 수. 나머지는 스냅샷으로 갱신됩니다.',
         snapshotInterval: '스냅샷 간격(초)',
-        snapshotIntervalHint: '비라이브 타일이 새 스냅샷을 가져오는 주기.'
+        snapshotIntervalHint: '비라이브 타일이 새 스냅샷을 가져오는 주기.',
+        statusOverlay: '상태 표시',
+        statusOverlayHint: '간단: 상태 배지만 표시. 전체: + 진행률, 레이어, 남은 시간.'
       }
     },
     hideOffline: '오프라인 숨기기',

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

@@ -204,12 +204,21 @@ export default {
       snap: 'Foto',
       off: 'Desligado',
       summary: '{{live}} ao vivo, {{snap}} fotos, {{total}} no total',
+      layer: 'Camada {{cur}}/{{total}}',
+      timeLeft: 'faltam {{time}}',
+      statusMode: {
+        off: 'Desligado',
+        compact: 'Compacto',
+        full: 'Completo',
+      },
       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.',
+        statusOverlay: 'Sobreposição de status',
+        statusOverlayHint: 'Compacto: apenas o selo de status. Completo: + progresso, camada e tempo restante.',
       },
     },
     // Controls

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

@@ -204,12 +204,21 @@ export default {
       snap: 'Foto',
       off: 'Kapalı',
       summary: '{{live}} canlı, {{snap}} fotoğraf, toplam {{total}}',
+      layer: 'Katman {{cur}}/{{total}}',
+      timeLeft: '{{time}} kaldı',
+      statusMode: {
+        off: 'Kapalı',
+        compact: 'Sade',
+        full: 'Tam',
+      },
       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ığı.',
+        statusOverlay: 'Durum kaplaması',
+        statusOverlayHint: 'Sade: yalnızca durum rozeti. Tam: + ilerleme, katman, kalan süre.',
       },
     },
     // Kontroller

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

@@ -204,12 +204,21 @@ export default {
       snap: '快照',
       off: '关闭',
       summary: '直播 {{live}} 个,快照 {{snap}} 个,共 {{total}} 个',
+      layer: '第 {{cur}}/{{total}} 层',
+      timeLeft: '剩余 {{time}}',
+      statusMode: {
+        off: '关闭',
+        compact: '简洁',
+        full: '完整',
+      },
       settings: {
         title: '摄像头墙设置',
         maxLive: '最大直播数',
         maxLiveHint: '同时直播的画面数量。其他画面以快照刷新。',
         snapshotInterval: '快照刷新间隔(秒)',
         snapshotIntervalHint: '非直播画面获取新快照的频率。',
+        statusOverlay: '状态叠加',
+        statusOverlayHint: '简洁:仅显示状态标签。完整:加上进度、层数、剩余时间。',
       },
     },
     // Controls

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

@@ -204,12 +204,21 @@ export default {
       snap: '快照',
       off: '關閉',
       summary: '直播 {{live}} 個,快照 {{snap}} 個,共 {{total}} 個',
+      layer: '第 {{cur}}/{{total}} 層',
+      timeLeft: '剩餘 {{time}}',
+      statusMode: {
+        off: '關閉',
+        compact: '精簡',
+        full: '完整',
+      },
       settings: {
         title: '攝影機牆設定',
         maxLive: '最大直播數',
         maxLiveHint: '同時直播的畫面數量。其他畫面以快照重新整理。',
         snapshotInterval: '快照重新整理間隔(秒)',
         snapshotIntervalHint: '非直播畫面取得新快照的頻率。',
+        statusOverlay: '狀態疊加',
+        statusOverlayHint: '精簡:僅顯示狀態標籤。完整:加上進度、層數、剩餘時間。',
       },
     },
     // Controls

+ 13 - 0
frontend/src/pages/PrintersPage.tsx

@@ -7627,6 +7627,14 @@ export function PrintersPage() {
     const saved = parseInt(localStorage.getItem('camWallSnapshotSec') || '', 10);
     return Number.isFinite(saved) && saved > 0 ? saved : 8;
   });
+  // 'off' hides the printer-state overlay; 'compact' shows only a state chip;
+  // 'full' adds progress, layer, and time-left on printing/paused tiles.
+  // Defaulting to 'full' because the cards already show this info — users who
+  // pick cam-wall view still want to glance the same details without flipping.
+  const [camWallStatusMode, setCamWallStatusMode] = useState<'off' | 'compact' | 'full'>(() => {
+    const saved = localStorage.getItem('camWallStatusMode');
+    return saved === 'off' || saved === 'compact' || saved === 'full' ? saved : 'full';
+  });
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const [compactDrilldownPrinterId, setCompactDrilldownPrinterId] = useState<number | null>(null);
@@ -8618,6 +8626,7 @@ export function PrintersPage() {
               window.open(`/camera/${id}`, `camera-${id}`, features);
             }
           }}
+          statusMode={camWallStatusMode}
           onChangeMaxLive={(next) => {
             setCamWallMaxLive(next);
             localStorage.setItem('camWallMaxLive', String(next));
@@ -8626,6 +8635,10 @@ export function PrintersPage() {
             setCamWallSnapshotSec(next);
             localStorage.setItem('camWallSnapshotSec', String(next));
           }}
+          onChangeStatusMode={(next) => {
+            setCamWallStatusMode(next);
+            localStorage.setItem('camWallStatusMode', next);
+          }}
         />
       ) : groupedPrinters ? (
         /* Grouped view (location, status, or model) */

File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/index-BKwIZ5yr.css


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-BgPfOybr.js


File diff ditekan karena terlalu besar
+ 1 - 0
static/assets/index-D4jvs18I.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-DqujanKU.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BKwIZ5yr.css">
+    <script type="module" crossorigin src="/assets/index-BgPfOybr.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D4jvs18I.css">
   </head>
   <body>
     <div id="root"></div>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini