Files
bambuddy/frontend/src/components/CameraWall.tsx
T
maziggy d09db436c3 feat(camwall): serve the Cam Wall at /camwall, and on a token-authenticated kiosk
Cam Wall had no URL — the only way in was the toggle on the Printers page,
so it could not be bookmarked, linked, or shown on a wall-mounted screen.

Add a standalone /camwall route. Signed in, it is the wall as it was. For a
TV or Pi with no login, it authenticates with a long-lived token in the URL.

A kiosk needs the printer list and per-printer status, both of which sit
behind PRINTERS_READ. Rather than widen camera_stream to cover GET /printers
— whose response carries serial_number and ip_address, which have no business
on a screen in a shared room — add a read-only feed at
GET /api/v1/camwall/printers that serves only what a tile draws, and gate it
on a new camwall token scope. The print filename is not served at all: a token
wall renders the compact overlay, so the part on the bed is never named.

The scope is separate rather than a widening: camera_stream tokens are already
in the wild, minted to hand out video, and must not gain the ability to
enumerate a fleet by name. camera_stream is refused by the feed; camwall
passes the stream gate so its own tiles fill.

Kiosk walls drop the settings popover and click-through entirely (not merely
hidden — a passive screen must carry no focusable control it cannot act on),
cap the overlay at compact, and poll rather than open a WebSocket. maxLive,
interval and status can be set from the URL, clamped to the popover's ranges.
2026-07-11 13:38:15 +02:00

315 lines
12 KiB
TypeScript

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 PrinterStatus } from '../api/client';
// The wall only ever reads these three fields off a printer, so it asks for no
// more than that. Printer[] satisfies this structurally, and so does the
// smaller payload the token-authenticated kiosk feed returns (#2531) — which
// deliberately carries neither serial number nor IP.
export interface CameraWallPrinter {
id: number;
name: string;
camera_rotation?: number;
}
// What a tile draws from a printer's status. PrinterStatus satisfies it; so
// does CamWallPrinter, which is how the kiosk page feeds the same component
// without the JWT-gated per-printer status endpoint.
export interface CameraWallStatus {
connected?: boolean;
state?: string | null;
progress?: number | null;
remaining_time?: number | null;
layer_num?: number | null;
total_layers?: number | null;
subtask_name?: string | null;
gcode_file?: string | null;
hms_errors?: PrinterStatus['hms_errors'];
}
interface CameraWallProps {
printers: CameraWallPrinter[];
maxLive: number;
snapshotIntervalSec: number;
statusMode: CameraTileStatusMode;
onChangeMaxLive: (next: number) => void;
onChangeSnapshotIntervalSec: (next: number) => void;
onChangeStatusMode: (next: CameraTileStatusMode) => void;
// Omitted on a kiosk wall: a TV has no pointer, and click-through would open
// a page the wall's token cannot authenticate. Tiles render inert instead.
onTileClick?: (printerId: number, printerName: string) => void;
// Supplied by the kiosk page, which polls one feed for the whole wall. When
// absent the component fetches per-printer status itself, reusing the
// ['printerStatus', id] cache the printer cards already populate.
statuses?: Map<number, CameraWallStatus | undefined>;
// Kiosk walls hide the settings popover — the knobs come from the URL, and
// there is nobody standing at the screen to turn them.
showSettings?: boolean;
}
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,
statuses,
showSettings: settingsEnabled = true,
}: 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. Skipped
// entirely when the caller already has the statuses — the kiosk page polls
// one feed for the whole wall, and its token cannot reach this endpoint.
const ownQueries = statuses ? [] : printers;
const statusQueries = useQueries({
queries: ownQueries.map((p) => ({
queryKey: ['printerStatus', p.id],
queryFn: () => api.getPrinterStatus(p.id),
staleTime: 5000,
})),
});
const fetchedStatuses = useMemo(() => {
const map = new Map<number, CameraWallStatus | undefined>();
ownQueries.forEach((p, i) => {
map.set(p.id, statusQueries[i]?.data);
});
return map;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [printers, statusQueries, statuses]);
const statusByPrinter = statuses ?? fetchedStatuses;
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). 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) {
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 (
<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>
{/* Not merely hidden — a kiosk wall must not carry a focusable control
it cannot act on. CSS-hiding would leave it tabbable. */}
{settingsEnabled && (
<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 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>
)}
</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={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 ? () => onTileClick(p.id, p.name) : undefined}
/>
</div>
);
})}
</div>
</div>
);
}