CameraTile.tsx 13 KB

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