CameraTile.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { useEffect, 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. export type CameraTileMode = 'live' | 'snapshot' | 'paused';
  7. export type CameraTileStatusMode = 'off' | 'compact' | 'full';
  8. interface CameraTileProps {
  9. printerId: number;
  10. printerName: string;
  11. cameraRotation?: number;
  12. mode: CameraTileMode;
  13. snapshotIntervalMs: number;
  14. connected: boolean;
  15. onClick?: () => void;
  16. // Optional status overlay — wired by CameraWall from the shared
  17. // ['printerStatus', id] query. All optional so existing tests don't break.
  18. statusMode?: CameraTileStatusMode;
  19. printerState?: string | null;
  20. progress?: number | null;
  21. remainingMin?: number | null;
  22. layerNum?: number | null;
  23. totalLayers?: number | null;
  24. printName?: string | null;
  25. hmsErrorCount?: number;
  26. }
  27. // Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
  28. // no drag/resize/zoom shell, and snapshot fallback when off-cap. The server
  29. // still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
  30. const LIVE_FPS = 8;
  31. type StatusBucket = 'printing' | 'paused' | 'finished' | 'error' | 'idle';
  32. function classifyState(state: string | null | undefined, hmsErrorCount: number): StatusBucket {
  33. if (hmsErrorCount > 0) return 'error';
  34. switch (state) {
  35. case 'RUNNING':
  36. return 'printing';
  37. case 'PAUSE':
  38. return 'paused';
  39. case 'FINISH':
  40. case 'FAILED':
  41. return 'finished';
  42. default:
  43. return 'idle';
  44. }
  45. }
  46. const BUCKET_CHIP_CLASS: Record<StatusBucket, string> = {
  47. printing: 'bg-bambu-green/85 text-black',
  48. paused: 'bg-amber-500/85 text-black',
  49. finished: 'bg-sky-500/80 text-white',
  50. error: 'bg-red-500/85 text-white',
  51. idle: 'bg-bambu-dark-tertiary/80 text-bambu-gray',
  52. };
  53. export function CameraTile({
  54. printerId,
  55. printerName,
  56. cameraRotation = 0,
  57. mode,
  58. snapshotIntervalMs,
  59. connected,
  60. onClick,
  61. statusMode = 'off',
  62. printerState = null,
  63. progress = null,
  64. remainingMin = null,
  65. layerNum = null,
  66. totalLayers = null,
  67. printName = null,
  68. hmsErrorCount = 0,
  69. }: CameraTileProps) {
  70. const { t } = useTranslation();
  71. const [bust, setBust] = useState(0);
  72. const [errored, setErrored] = useState(false);
  73. const lastModeRef = useRef<CameraTileMode>(mode);
  74. // Tell the backend to release its MJPEG transcoder when this tile stops
  75. // being live — either by unmounting or by transitioning to snapshot/paused.
  76. // EmbeddedCameraViewer uses the same /camera/stop with keepalive on unmount.
  77. useEffect(() => {
  78. const wasLive = lastModeRef.current === 'live';
  79. const isLive = mode === 'live';
  80. lastModeRef.current = mode;
  81. if (wasLive && !isLive) {
  82. const headers: Record<string, string> = {};
  83. const token = getAuthToken();
  84. if (token) headers['Authorization'] = `Bearer ${token}`;
  85. fetch(`/api/v1/printers/${printerId}/camera/stop`, {
  86. method: 'POST',
  87. keepalive: true,
  88. headers,
  89. }).catch(() => {});
  90. }
  91. setErrored(false);
  92. setBust((b) => b + 1);
  93. }, [mode, printerId]);
  94. useEffect(() => {
  95. return () => {
  96. if (lastModeRef.current === 'live') {
  97. const headers: Record<string, string> = {};
  98. const token = getAuthToken();
  99. if (token) headers['Authorization'] = `Bearer ${token}`;
  100. fetch(`/api/v1/printers/${printerId}/camera/stop`, {
  101. method: 'POST',
  102. keepalive: true,
  103. headers,
  104. }).catch(() => {});
  105. }
  106. };
  107. }, [printerId]);
  108. useEffect(() => {
  109. if (mode !== 'snapshot') return;
  110. const interval = setInterval(() => setBust((b) => b + 1), snapshotIntervalMs);
  111. return () => clearInterval(interval);
  112. }, [mode, snapshotIntervalMs]);
  113. const liveUrl = withStreamToken(
  114. `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`,
  115. );
  116. const snapshotUrl = withStreamToken(
  117. `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
  118. );
  119. // A kiosk wall passes no onClick — there is no pointer at a TV, and the page
  120. // is authenticated by a token that cannot open the single-camera view. Render
  121. // the tile as plain, non-focusable content rather than a button that looks
  122. // clickable and then does nothing.
  123. const interactive = onClick != null;
  124. const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
  125. const bucket = classifyState(printerState, hmsErrorCount);
  126. // Hide chip for idle to keep cold walls clean; always show when something
  127. // is happening (printing/paused/finished/error).
  128. const showChip = connected && statusMode !== 'off' && bucket !== 'idle';
  129. const isPrintingOrPaused = bucket === 'printing' || bucket === 'paused';
  130. const showInfoStrip = connected && statusMode === 'full' && isPrintingOrPaused;
  131. const fileLabel = printName ?? null;
  132. const progressPct = progress != null ? Math.round(progress) : null;
  133. const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0;
  134. const hasRemaining = remainingMin != null && remainingMin > 0;
  135. const rootClass = `group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left ${
  136. interactive ? 'focus:outline-none focus:ring-2 focus:ring-bambu-green' : 'cursor-default'
  137. }`;
  138. const content = (
  139. <>
  140. {!connected || mode === 'paused' ? (
  141. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/60">
  142. {connected ? (
  143. <VideoOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
  144. ) : (
  145. <WifiOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
  146. )}
  147. </div>
  148. ) : errored ? (
  149. <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/80 text-bambu-gray">
  150. <VideoOff className="h-7 w-7" aria-hidden="true" />
  151. <span className="text-xs">{t('printers.camWall.noSignal')}</span>
  152. </div>
  153. ) : (
  154. <img
  155. key={`${mode}-${bust}`}
  156. src={mode === 'live' ? liveUrl : snapshotUrl}
  157. alt={printerName}
  158. draggable={false}
  159. loading="lazy"
  160. className="h-full w-full select-none object-contain"
  161. style={{ transform }}
  162. onError={() => setErrored(true)}
  163. />
  164. )}
  165. {/* Status chip (top-left) */}
  166. {showChip && (
  167. <span
  168. 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]}`}
  169. >
  170. {hmsErrorCount > 0 && (
  171. <AlertTriangle
  172. className="h-3 w-3"
  173. aria-hidden="true"
  174. />
  175. )}
  176. <span>{t(`printers.status.${bucket}`)}</span>
  177. </span>
  178. )}
  179. {/* Mode indicator (top-right) */}
  180. <span
  181. className={`absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
  182. mode === 'live'
  183. ? 'bg-red-500/80 text-white'
  184. : mode === 'snapshot'
  185. ? 'bg-amber-500/70 text-black'
  186. : 'bg-bambu-dark-tertiary/70 text-bambu-gray'
  187. }`}
  188. >
  189. {mode === 'live'
  190. ? t('printers.camWall.live')
  191. : mode === 'snapshot'
  192. ? t('printers.camWall.snap')
  193. : t('printers.camWall.off')}
  194. </span>
  195. {/* Bottom overlay: name + (when full) print info */}
  196. <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">
  197. {showInfoStrip && (
  198. <div className="mb-0.5 space-y-0.5 text-[11px] leading-tight text-white/90">
  199. {fileLabel && (
  200. <div className="truncate" title={fileLabel}>
  201. {fileLabel}
  202. </div>
  203. )}
  204. <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-bambu-gray">
  205. {progressPct != null && (
  206. <span className="font-semibold text-white">{progressPct}%</span>
  207. )}
  208. {hasLayers && (
  209. <span>
  210. {t('printers.camWall.layer', {
  211. cur: layerNum,
  212. total: totalLayers,
  213. })}
  214. </span>
  215. )}
  216. {hasRemaining && (
  217. <span>
  218. {t('printers.camWall.timeLeft', {
  219. time: formatDuration((remainingMin ?? 0) * 60),
  220. })}
  221. </span>
  222. )}
  223. </div>
  224. </div>
  225. )}
  226. <span className="block truncate text-xs font-medium">{printerName}</span>
  227. </div>
  228. </>
  229. );
  230. if (!interactive) {
  231. return (
  232. <div className={rootClass} title={printerName}>
  233. {content}
  234. </div>
  235. );
  236. }
  237. return (
  238. <button type="button" onClick={onClick} className={rootClass} title={printerName}>
  239. {content}
  240. </button>
  241. );
  242. }