CameraTile.tsx 15 KB

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