CameraTile.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { AlertTriangle, Box, Flame, RectangleHorizontal, VideoOff, WifiOff } from 'lucide-react';
  4. import { getAuthToken, withStreamToken } from '../api/client';
  5. import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
  6. import { useStreamImgRef } from '../hooks/useStreamImgRef';
  7. import { useCameraViewerId } from '../hooks/useCameraViewerId';
  8. import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
  9. export type CameraTileMode = 'live' | 'snapshot' | 'paused';
  10. export type CameraTileStatusMode = 'off' | 'compact' | 'full';
  11. /** The heater readings a tile shows in the full overlay (#3479). */
  12. export interface CameraTileTemperatures {
  13. nozzle?: number;
  14. nozzle_2?: number;
  15. bed?: number;
  16. chamber?: number;
  17. }
  18. interface CameraTileProps {
  19. printerId: number;
  20. printerName: string;
  21. cameraRotation?: number;
  22. mode: CameraTileMode;
  23. snapshotIntervalMs: number;
  24. connected: boolean;
  25. onClick?: () => void;
  26. // Optional status overlay — wired by CameraWall from the shared
  27. // ['printerStatus', id] query. All optional so existing tests don't break.
  28. statusMode?: CameraTileStatusMode;
  29. printerState?: string | null;
  30. progress?: number | null;
  31. remainingMin?: number | null;
  32. layerNum?: number | null;
  33. totalLayers?: number | null;
  34. printName?: string | null;
  35. hmsErrorCount?: number;
  36. // Full overlay only (#3479): the heaters, on every connected tile, and the
  37. // finish time beside the time left while printing.
  38. temperatures?: CameraTileTemperatures | null;
  39. timeFormat?: TimeFormat;
  40. }
  41. // Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
  42. // no drag/resize/zoom shell, and snapshot fallback when off-cap. The server
  43. // still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
  44. const LIVE_FPS = 8;
  45. const LIVE_RENEWAL_MS = 60_000;
  46. // Between closing the old stream and opening the new one: a USB camera serves
  47. // one reader, and the old ffmpeg must let go of the device first.
  48. const RENEWAL_GAP_MS = 200;
  49. // A renewal that failed is tried once more after this.
  50. const RENEWAL_RETRY_MS = 5_000;
  51. // A renewal with no frame by then has failed too: a snapshot camera that is
  52. // down keeps its stream open without one, and the img fires no event at all.
  53. const RENEWAL_FIRST_FRAME_MS = 15_000;
  54. type StatusBucket = 'printing' | 'paused' | 'finished' | 'error' | 'idle';
  55. function classifyState(state: string | null | undefined, hmsErrorCount: number): StatusBucket {
  56. if (hmsErrorCount > 0) return 'error';
  57. switch (state) {
  58. case 'RUNNING':
  59. return 'printing';
  60. case 'PAUSE':
  61. return 'paused';
  62. case 'FINISH':
  63. case 'FAILED':
  64. return 'finished';
  65. default:
  66. return 'idle';
  67. }
  68. }
  69. const BUCKET_CHIP_CLASS: Record<StatusBucket, string> = {
  70. printing: 'bg-bambu-green/85 text-black',
  71. paused: 'bg-amber-500/85 text-black',
  72. finished: 'bg-sky-500/80 text-white',
  73. error: 'bg-red-500/85 text-white',
  74. idle: 'bg-bambu-dark-tertiary/80 text-bambu-gray',
  75. };
  76. export function CameraTile({
  77. printerId,
  78. printerName,
  79. cameraRotation = 0,
  80. mode,
  81. snapshotIntervalMs,
  82. connected,
  83. onClick,
  84. statusMode = 'off',
  85. printerState = null,
  86. progress = null,
  87. remainingMin = null,
  88. layerNum = null,
  89. totalLayers = null,
  90. printName = null,
  91. hmsErrorCount = 0,
  92. temperatures = null,
  93. timeFormat = 'system',
  94. }: CameraTileProps) {
  95. const { t } = useTranslation();
  96. const [bust, setBust] = useState(0);
  97. const [errored, setErrored] = useState(false);
  98. // The old stream is closed and the new one not yet due: the img renders with
  99. // no src (removing it closes the connection), so no re-render reopens it early.
  100. const [reopening, setReopening] = useState(false);
  101. const lastModeRef = useRef<CameraTileMode>(mode);
  102. // Every img this tile drops (leaving live mode, a renewal, unmounting) has
  103. // its stream closed; the /camera/stop sent on leaving live mode is skipped
  104. // while a dropped img still holds it.
  105. const imgRef = useRef<HTMLImageElement | null>(null);
  106. // On its stream and its /camera/stop: the stop ends this tile's stream only.
  107. const viewerId = useCameraViewerId();
  108. // 'renewed': the current img is a renewal not yet showing a frame; 'retried':
  109. // it is the one retry of a renewal that failed.
  110. const renewalRef = useRef<'none' | 'renewed' | 'retried'>('none');
  111. // A failed snapshot is retried once at the next refresh: the server answers
  112. // 503 while a live view of this printer is still starting (it will not take
  113. // the camera from that viewer). Not more: each failed camera img re-mints the
  114. // page's stream token.
  115. const snapshotRetriedRef = useRef(false);
  116. const renewalTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  117. const firstFrameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  118. // Tell the backend to release its MJPEG transcoder when this tile stops
  119. // being live — either by unmounting or by transitioning to snapshot/paused.
  120. // EmbeddedCameraViewer uses the same /camera/stop with keepalive on unmount.
  121. useEffect(() => {
  122. const wasLive = lastModeRef.current === 'live';
  123. const isLive = mode === 'live';
  124. lastModeRef.current = mode;
  125. if (wasLive && !isLive) {
  126. const headers: Record<string, string> = {};
  127. const token = getAuthToken();
  128. if (token) headers['Authorization'] = `Bearer ${token}`;
  129. fetch(`/api/v1/printers/${printerId}/camera/stop?viewer=${viewerId}`, {
  130. method: 'POST',
  131. keepalive: true,
  132. headers,
  133. }).catch(() => {});
  134. }
  135. setErrored(false);
  136. snapshotRetriedRef.current = false;
  137. setBust((b) => b + 1);
  138. }, [mode, printerId, viewerId]);
  139. useEffect(() => {
  140. return () => {
  141. if (lastModeRef.current === 'live') {
  142. const headers: Record<string, string> = {};
  143. const token = getAuthToken();
  144. if (token) headers['Authorization'] = `Bearer ${token}`;
  145. fetch(`/api/v1/printers/${printerId}/camera/stop?viewer=${viewerId}`, {
  146. method: 'POST',
  147. keepalive: true,
  148. headers,
  149. }).catch(() => {});
  150. }
  151. };
  152. }, [printerId, viewerId]);
  153. useEffect(() => {
  154. if (mode !== 'snapshot') return;
  155. const interval = setInterval(() => setBust((b) => b + 1), snapshotIntervalMs);
  156. return () => clearInterval(interval);
  157. }, [mode, snapshotIntervalMs]);
  158. // A stream the server ended (its re-check, the camera dropping) fires no img
  159. // event: the tile would keep its last frame. Renew it, closing the old
  160. // connection first (a detached img keeps it open). No /camera/stop: other
  161. // viewers of the printer share the stream. Not in a hidden page, where the
  162. // stream token is not refreshed. A tile showing no signal is left alone:
  163. // each failed camera img makes the app re-mint the stream token and
  164. // reconnect every camera on the page.
  165. //
  166. // Built once: they only use refs and state setters, and call each other.
  167. const { clearFirstFrameTimer, streamFailed, reopenAfter } = useMemo(() => {
  168. const clearFirstFrameTimer = () => {
  169. if (firstFrameTimerRef.current !== null) clearTimeout(firstFrameTimerRef.current);
  170. firstFrameTimerRef.current = null;
  171. };
  172. // The stream failed (an error, or a renewal with no frame in time): a
  173. // renewal is retried once, anything else shows no signal.
  174. const streamFailed = () => {
  175. clearFirstFrameTimer();
  176. if (renewalRef.current === 'renewed') {
  177. renewalRef.current = 'retried';
  178. reopenAfter(RENEWAL_RETRY_MS);
  179. return;
  180. }
  181. renewalRef.current = 'none';
  182. setErrored(true);
  183. };
  184. const reopenAfter = (ms: number) => {
  185. // Closed now, not at the next render: React may batch this with the reopen.
  186. imgRef.current?.removeAttribute('src');
  187. setReopening(true);
  188. if (renewalTimerRef.current !== null) clearTimeout(renewalTimerRef.current);
  189. renewalTimerRef.current = setTimeout(() => {
  190. renewalTimerRef.current = null;
  191. setReopening(false);
  192. setBust((b) => b + 1);
  193. clearFirstFrameTimer();
  194. // A hidden page does not load a lazy img: that time does not count.
  195. const deadline = () => {
  196. if (document.hidden) {
  197. firstFrameTimerRef.current = setTimeout(deadline, RENEWAL_FIRST_FRAME_MS);
  198. return;
  199. }
  200. streamFailed();
  201. };
  202. firstFrameTimerRef.current = setTimeout(deadline, RENEWAL_FIRST_FRAME_MS);
  203. }, ms);
  204. };
  205. return { clearFirstFrameTimer, streamFailed, reopenAfter };
  206. }, []);
  207. useEffect(() => {
  208. renewalRef.current = 'none';
  209. if (mode !== 'live' || !connected) return;
  210. const interval = setInterval(() => {
  211. if (document.hidden || !imgRef.current || renewalTimerRef.current !== null) return;
  212. renewalRef.current = 'renewed';
  213. reopenAfter(RENEWAL_GAP_MS);
  214. }, LIVE_RENEWAL_MS);
  215. return () => {
  216. clearInterval(interval);
  217. if (renewalTimerRef.current !== null) clearTimeout(renewalTimerRef.current);
  218. renewalTimerRef.current = null;
  219. clearFirstFrameTimer();
  220. setReopening(false);
  221. };
  222. }, [mode, connected, reopenAfter, clearFirstFrameTimer]);
  223. const liveUrl = withStreamToken(
  224. `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}&viewer=${viewerId}`,
  225. );
  226. const snapshotUrl = withStreamToken(
  227. `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
  228. );
  229. // The img gets its src from here, at commit, never from a prop (#3431).
  230. const attachImg = useStreamImgRef(imgRef, reopening ? undefined : mode === 'live' ? liveUrl : snapshotUrl);
  231. // A kiosk wall passes no onClick — there is no pointer at a TV, and the page
  232. // is authenticated by a token that cannot open the single-camera view. Render
  233. // the tile as plain, non-focusable content rather than a button that looks
  234. // clickable and then does nothing.
  235. const interactive = onClick != null;
  236. // A sideways camera is laid out at the tile's sides swapped and centred, or
  237. // the rotated picture is cut off at the tile's top and bottom (#3362).
  238. // One ref for either root: the tile is a button when it can be clicked.
  239. const tileRef = useRef<HTMLDivElement & HTMLButtonElement>(null);
  240. const sideways = isSideways(cameraRotation);
  241. const tile = useSidewaysBox(tileRef, sideways);
  242. const imgStyle: CSSProperties | undefined =
  243. sideways && tile
  244. ? {
  245. position: 'absolute',
  246. left: '50%',
  247. top: '50%',
  248. width: tile.height,
  249. height: tile.width,
  250. maxWidth: 'none',
  251. transform: `translate(-50%, -50%) rotate(${cameraRotation}deg)`,
  252. }
  253. : cameraRotation
  254. ? { transform: `rotate(${cameraRotation}deg)` }
  255. : undefined;
  256. const bucket = classifyState(printerState, hmsErrorCount);
  257. // Hide chip for idle to keep cold walls clean; always show when something
  258. // is happening (printing/paused/finished/error).
  259. const showChip = connected && statusMode !== 'off' && bucket !== 'idle';
  260. const isPrintingOrPaused = bucket === 'printing' || bucket === 'paused';
  261. const showInfoStrip = connected && statusMode === 'full' && isPrintingOrPaused;
  262. const fileLabel = printName ?? null;
  263. const progressPct = progress != null ? Math.round(progress) : null;
  264. const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0;
  265. const hasRemaining = remainingMin != null && remainingMin > 0;
  266. // Heaters a printer has no sensor for are absent from its status, not 0.
  267. const showTemps = connected && statusMode === 'full' && temperatures != null;
  268. const nozzleTemps = [temperatures?.nozzle, temperatures?.nozzle_2].filter(
  269. (v): v is number => v != null,
  270. );
  271. const rootClass = `group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left ${
  272. interactive ? 'focus:outline-none focus:ring-2 focus:ring-bambu-green' : 'cursor-default'
  273. }`;
  274. const content = (
  275. <>
  276. {!connected || mode === 'paused' ? (
  277. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/60">
  278. {connected ? (
  279. <VideoOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
  280. ) : (
  281. <WifiOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
  282. )}
  283. </div>
  284. ) : errored ? (
  285. <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/80 text-bambu-gray">
  286. <VideoOff className="h-7 w-7" aria-hidden="true" />
  287. <span className="text-xs">{t('printers.camWall.noSignal')}</span>
  288. </div>
  289. ) : (
  290. <img
  291. ref={attachImg}
  292. key={`${mode}-${bust}`}
  293. alt={printerName}
  294. draggable={false}
  295. loading="lazy"
  296. className="h-full w-full select-none object-contain"
  297. style={imgStyle}
  298. onLoad={() => {
  299. clearFirstFrameTimer();
  300. renewalRef.current = 'none';
  301. snapshotRetriedRef.current = false;
  302. }}
  303. onError={(e) => {
  304. // An img with an empty or missing src is closed, not failed.
  305. if (!e.currentTarget.getAttribute('src')) return;
  306. if (mode === 'snapshot' && !snapshotRetriedRef.current) {
  307. snapshotRetriedRef.current = true;
  308. return;
  309. }
  310. streamFailed();
  311. }}
  312. />
  313. )}
  314. {/* Status chip (top-left) */}
  315. {showChip && (
  316. <span
  317. 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]}`}
  318. >
  319. {hmsErrorCount > 0 && (
  320. <AlertTriangle
  321. className="h-3 w-3"
  322. aria-hidden="true"
  323. />
  324. )}
  325. <span>{t(`printers.status.${bucket}`)}</span>
  326. </span>
  327. )}
  328. {/* Mode indicator (top-right) */}
  329. <span
  330. className={`absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
  331. mode === 'live'
  332. ? 'bg-red-500/80 text-white'
  333. : mode === 'snapshot'
  334. ? 'bg-amber-500/70 text-black'
  335. : 'bg-bambu-dark-tertiary/70 text-bambu-gray'
  336. }`}
  337. >
  338. {mode === 'live'
  339. ? t('printers.camWall.live')
  340. : mode === 'snapshot'
  341. ? t('printers.camWall.snap')
  342. : t('printers.camWall.off')}
  343. </span>
  344. {/* Bottom overlay: name + (when full) print info */}
  345. <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">
  346. {showInfoStrip && (
  347. <div className="mb-0.5 space-y-0.5 text-[11px] leading-tight text-white/90">
  348. {fileLabel && (
  349. <div className="truncate" title={fileLabel}>
  350. {fileLabel}
  351. </div>
  352. )}
  353. <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-bambu-gray">
  354. {progressPct != null && (
  355. <span className="font-semibold text-white">{progressPct}%</span>
  356. )}
  357. {hasLayers && (
  358. <span>
  359. {t('printers.camWall.layer', {
  360. cur: layerNum,
  361. total: totalLayers,
  362. })}
  363. </span>
  364. )}
  365. {hasRemaining && (
  366. <span>
  367. {t('printers.camWall.timeLeft', {
  368. time: formatDuration((remainingMin ?? 0) * 60),
  369. })}
  370. </span>
  371. )}
  372. {/* A paused print has no finish time: it moves with the pause. */}
  373. {hasRemaining && bucket === 'printing' && (
  374. <span>
  375. {t('printers.camWall.eta', { time: formatETA(remainingMin ?? 0, timeFormat, t) })}
  376. </span>
  377. )}
  378. </div>
  379. </div>
  380. )}
  381. {showTemps && (
  382. <div className="mb-0.5 flex flex-wrap items-center gap-x-2 text-[11px] leading-tight text-bambu-gray">
  383. {nozzleTemps.length > 0 && (
  384. <span className="flex items-center gap-0.5" title={t('printers.temperatures.nozzle')}>
  385. <Flame className="h-3 w-3" aria-label={t('printers.temperatures.nozzle')} />
  386. {nozzleTemps.map((v) => `${Math.round(v)}°`).join(' / ')}
  387. </span>
  388. )}
  389. {temperatures?.bed != null && (
  390. <span className="flex items-center gap-0.5" title={t('printers.temperatures.bed')}>
  391. <RectangleHorizontal className="h-3 w-3" aria-label={t('printers.temperatures.bed')} />
  392. {Math.round(temperatures.bed)}°
  393. </span>
  394. )}
  395. {temperatures?.chamber != null && (
  396. <span className="flex items-center gap-0.5" title={t('printers.temperatures.chamber')}>
  397. <Box className="h-3 w-3" aria-label={t('printers.temperatures.chamber')} />
  398. {Math.round(temperatures.chamber)}°
  399. </span>
  400. )}
  401. </div>
  402. )}
  403. <span className="block truncate text-xs font-medium">{printerName}</span>
  404. </div>
  405. </>
  406. );
  407. if (!interactive) {
  408. return (
  409. <div ref={tileRef} className={rootClass} title={printerName}>
  410. {content}
  411. </div>
  412. );
  413. }
  414. return (
  415. <button ref={tileRef} type="button" onClick={onClick} className={rootClass} title={printerName}>
  416. {content}
  417. </button>
  418. );
  419. }