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).
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.