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, 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>(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 statusByPrinter = useMemo(() => { const map = new Map(); printers.forEach((p, i) => { map.set(p.id, statusQueries[i]?.data); }); return map; }, [printers, statusQueries]); const [visibleIds, setVisibleIds] = useState>(() => new Set()); const [showSettings, setShowSettings] = useState(false); const settingsRef = useRef(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). 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(); let liveBudget = Math.max(0, maxLive); for (const p of printers) { const connected = statusByPrinter.get(p.id)?.connected ?? false; if (!visibleIds.has(p.id) || !connected) { 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, statusByPrinter]); if (printers.length === 0) { return (
{t('printers.camWall.noPrinters')}
); } return (
{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, })}
{showSettings && (
{t('printers.camWall.settings.statusOverlay')}
{STATUS_MODES.map((m) => ( ))}
{t('printers.camWall.settings.statusOverlayHint')}
)}
{printers.map((p) => { const mode = modeByPrinter.get(p.id) ?? 'paused'; return (
{ tileRefs.current.set(p.id, el); }} data-printer-id={p.id} > onTileClick(p.id, p.name)} />
); })}
); }