CameraWall.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { useEffect, useMemo, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQueries } from '@tanstack/react-query';
  4. import { Settings as SettingsIcon } from 'lucide-react';
  5. import { CameraTile, type CameraTileMode, type CameraTileStatusMode } from './CameraTile';
  6. import { filterKnownHMSErrors } from './HMSErrorModal';
  7. import { api, type Printer, type PrinterStatus } from '../api/client';
  8. interface CameraWallProps {
  9. printers: Printer[];
  10. maxLive: number;
  11. snapshotIntervalSec: number;
  12. statusMode: CameraTileStatusMode;
  13. onTileClick: (printerId: number, printerName: string) => void;
  14. onChangeMaxLive: (next: number) => void;
  15. onChangeSnapshotIntervalSec: (next: number) => void;
  16. onChangeStatusMode: (next: CameraTileStatusMode) => void;
  17. }
  18. const MIN_MAX_LIVE = 1;
  19. const MAX_MAX_LIVE = 16;
  20. const MIN_SNAPSHOT_SEC = 2;
  21. const MAX_SNAPSHOT_SEC = 60;
  22. const STATUS_MODES: CameraTileStatusMode[] = ['off', 'compact', 'full'];
  23. export function CameraWall({
  24. printers,
  25. maxLive,
  26. snapshotIntervalSec,
  27. statusMode,
  28. onTileClick,
  29. onChangeMaxLive,
  30. onChangeSnapshotIntervalSec,
  31. onChangeStatusMode,
  32. }: CameraWallProps) {
  33. const { t } = useTranslation();
  34. const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
  35. // Reuses the same ['printerStatus', id] cache that each PrinterCard
  36. // populates, so flipping between Cards and Cam Wall is instant.
  37. const statusQueries = useQueries({
  38. queries: printers.map((p) => ({
  39. queryKey: ['printerStatus', p.id],
  40. queryFn: () => api.getPrinterStatus(p.id),
  41. staleTime: 5000,
  42. })),
  43. });
  44. const statusByPrinter = useMemo(() => {
  45. const map = new Map<number, PrinterStatus | undefined>();
  46. printers.forEach((p, i) => {
  47. map.set(p.id, statusQueries[i]?.data);
  48. });
  49. return map;
  50. }, [printers, statusQueries]);
  51. const [visibleIds, setVisibleIds] = useState<Set<number>>(() => new Set());
  52. const [showSettings, setShowSettings] = useState(false);
  53. const settingsRef = useRef<HTMLDivElement | null>(null);
  54. useEffect(() => {
  55. if (!showSettings) return;
  56. const handler = (e: MouseEvent) => {
  57. if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
  58. setShowSettings(false);
  59. }
  60. };
  61. document.addEventListener('mousedown', handler);
  62. return () => document.removeEventListener('mousedown', handler);
  63. }, [showSettings]);
  64. // IntersectionObserver: a tile is "visible" when ≥40% of it is on-screen.
  65. // 40% (not 0%) avoids flicker at scroll boundaries where a tile is fractionally
  66. // visible — we don't want to spin up a live stream for a 5-pixel sliver.
  67. useEffect(() => {
  68. const observer = new IntersectionObserver(
  69. (entries) => {
  70. setVisibleIds((prev) => {
  71. const next = new Set(prev);
  72. for (const entry of entries) {
  73. const id = Number((entry.target as HTMLElement).dataset.printerId);
  74. if (!Number.isFinite(id)) continue;
  75. if (entry.isIntersecting) next.add(id);
  76. else next.delete(id);
  77. }
  78. return next;
  79. });
  80. },
  81. { threshold: 0.4 },
  82. );
  83. for (const [, el] of tileRefs.current) {
  84. if (el) observer.observe(el);
  85. }
  86. return () => observer.disconnect();
  87. }, [printers]);
  88. // Live slot allocation: visible tiles get live up to `maxLive`, in printer
  89. // list order so the assignment is stable. Visible-but-over-cap fall back to
  90. // snapshot polling. Off-screen tiles render paused (no network). Disconnected
  91. // printers also render paused regardless of visibility — there's nothing to
  92. // stream and burning a live-budget slot on them would starve a working tile.
  93. const modeByPrinter = useMemo(() => {
  94. const map = new Map<number, CameraTileMode>();
  95. let liveBudget = Math.max(0, maxLive);
  96. for (const p of printers) {
  97. const connected = statusByPrinter.get(p.id)?.connected ?? false;
  98. if (!visibleIds.has(p.id) || !connected) {
  99. map.set(p.id, 'paused');
  100. continue;
  101. }
  102. if (liveBudget > 0) {
  103. map.set(p.id, 'live');
  104. liveBudget -= 1;
  105. } else {
  106. map.set(p.id, 'snapshot');
  107. }
  108. }
  109. return map;
  110. }, [printers, visibleIds, maxLive, statusByPrinter]);
  111. if (printers.length === 0) {
  112. return (
  113. <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-6 text-center text-bambu-gray">
  114. {t('printers.camWall.noPrinters')}
  115. </div>
  116. );
  117. }
  118. return (
  119. <div className="space-y-3">
  120. <div className="flex items-center justify-between text-xs text-bambu-gray">
  121. <span>
  122. {t('printers.camWall.summary', {
  123. live: Array.from(modeByPrinter.values()).filter((m) => m === 'live').length,
  124. snap: Array.from(modeByPrinter.values()).filter((m) => m === 'snapshot').length,
  125. total: printers.length,
  126. })}
  127. </span>
  128. <div className="relative" ref={settingsRef}>
  129. <button
  130. type="button"
  131. onClick={() => setShowSettings((v) => !v)}
  132. 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"
  133. title={t('printers.camWall.settings.title')}
  134. >
  135. <SettingsIcon className="h-3.5 w-3.5" />
  136. <span>{t('printers.camWall.settings.title')}</span>
  137. </button>
  138. {showSettings && (
  139. <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">
  140. <label className="block space-y-1">
  141. <span className="text-xs font-medium text-white">
  142. {t('printers.camWall.settings.maxLive')}
  143. </span>
  144. <input
  145. type="number"
  146. min={MIN_MAX_LIVE}
  147. max={MAX_MAX_LIVE}
  148. value={maxLive}
  149. onChange={(e) => {
  150. const n = Math.min(
  151. MAX_MAX_LIVE,
  152. Math.max(MIN_MAX_LIVE, Number(e.target.value) || MIN_MAX_LIVE),
  153. );
  154. onChangeMaxLive(n);
  155. }}
  156. className="w-full rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-sm text-white"
  157. />
  158. <span className="block text-[11px] text-bambu-gray">
  159. {t('printers.camWall.settings.maxLiveHint')}
  160. </span>
  161. </label>
  162. <label className="block space-y-1">
  163. <span className="text-xs font-medium text-white">
  164. {t('printers.camWall.settings.snapshotInterval')}
  165. </span>
  166. <input
  167. type="number"
  168. min={MIN_SNAPSHOT_SEC}
  169. max={MAX_SNAPSHOT_SEC}
  170. value={snapshotIntervalSec}
  171. onChange={(e) => {
  172. const n = Math.min(
  173. MAX_SNAPSHOT_SEC,
  174. Math.max(MIN_SNAPSHOT_SEC, Number(e.target.value) || MIN_SNAPSHOT_SEC),
  175. );
  176. onChangeSnapshotIntervalSec(n);
  177. }}
  178. className="w-full rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-sm text-white"
  179. />
  180. <span className="block text-[11px] text-bambu-gray">
  181. {t('printers.camWall.settings.snapshotIntervalHint')}
  182. </span>
  183. </label>
  184. <div className="space-y-1">
  185. <span className="block text-xs font-medium text-white">
  186. {t('printers.camWall.settings.statusOverlay')}
  187. </span>
  188. <div
  189. role="radiogroup"
  190. aria-label={t('printers.camWall.settings.statusOverlay')}
  191. className="flex overflow-hidden rounded-md border border-bambu-dark-tertiary"
  192. >
  193. {STATUS_MODES.map((m) => (
  194. <button
  195. key={m}
  196. type="button"
  197. role="radio"
  198. aria-checked={statusMode === m}
  199. onClick={() => onChangeStatusMode(m)}
  200. className={`flex-1 px-2 py-1 text-xs ${
  201. statusMode === m
  202. ? 'bg-bambu-green text-black font-semibold'
  203. : 'bg-bambu-dark text-white hover:bg-bambu-dark-tertiary'
  204. }`}
  205. >
  206. {t(`printers.camWall.statusMode.${m}`)}
  207. </button>
  208. ))}
  209. </div>
  210. <span className="block text-[11px] text-bambu-gray">
  211. {t('printers.camWall.settings.statusOverlayHint')}
  212. </span>
  213. </div>
  214. </div>
  215. )}
  216. </div>
  217. </div>
  218. <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
  219. {printers.map((p) => {
  220. const mode = modeByPrinter.get(p.id) ?? 'paused';
  221. return (
  222. <div
  223. key={p.id}
  224. ref={(el) => {
  225. tileRefs.current.set(p.id, el);
  226. }}
  227. data-printer-id={p.id}
  228. >
  229. <CameraTile
  230. printerId={p.id}
  231. printerName={p.name}
  232. cameraRotation={p.camera_rotation}
  233. mode={mode}
  234. snapshotIntervalMs={snapshotIntervalSec * 1000}
  235. connected={statusByPrinter.get(p.id)?.connected ?? false}
  236. statusMode={statusMode}
  237. printerState={statusByPrinter.get(p.id)?.state ?? null}
  238. progress={statusByPrinter.get(p.id)?.progress ?? null}
  239. remainingMin={statusByPrinter.get(p.id)?.remaining_time ?? null}
  240. layerNum={statusByPrinter.get(p.id)?.layer_num ?? null}
  241. totalLayers={statusByPrinter.get(p.id)?.total_layers ?? null}
  242. printName={
  243. statusByPrinter.get(p.id)?.subtask_name ??
  244. statusByPrinter.get(p.id)?.gcode_file ??
  245. null
  246. }
  247. hmsErrorCount={
  248. filterKnownHMSErrors(statusByPrinter.get(p.id)?.hms_errors ?? []).length
  249. }
  250. onClick={() => onTileClick(p.id, p.name)}
  251. />
  252. </div>
  253. );
  254. })}
  255. </div>
  256. </div>
  257. );
  258. }