StreamOverlayPage.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useParams, useSearchParams } from 'react-router-dom';
  3. import { useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import { Layers, Clock, Timer, Printer } from 'lucide-react';
  6. import { api, withStreamToken } from '../api/client';
  7. import type { PrinterStatus } from '../api/client';
  8. import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
  9. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  10. type OverlaySize = 'small' | 'medium' | 'large';
  11. interface OverlayConfig {
  12. size: OverlaySize;
  13. fps: number;
  14. showCamera: boolean;
  15. showProgress: boolean;
  16. showLayers: boolean;
  17. showEta: boolean;
  18. showFilename: boolean;
  19. showStatus: boolean;
  20. showPrinter: boolean;
  21. }
  22. function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
  23. if (!name) return '';
  24. if (!gcodeFile) return name;
  25. const match = gcodeFile.match(/plate_(\d+)\.gcode/);
  26. if (match && parseInt(match[1], 10) > 1) {
  27. return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`;
  28. }
  29. return name;
  30. }
  31. function parseConfig(params: URLSearchParams): OverlayConfig {
  32. const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
  33. // Parse FPS (default 15, max 30, min 1)
  34. const fpsParam = parseInt(params.get('fps') || '15', 10);
  35. const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
  36. // Parse camera toggle (default true, set camera=false to hide)
  37. const cameraParam = params.get('camera');
  38. const showCamera = cameraParam !== 'false' && cameraParam !== '0';
  39. return {
  40. size: (params.get('size') as OverlaySize) || 'medium',
  41. fps,
  42. showCamera,
  43. showProgress: show.includes('progress'),
  44. showLayers: show.includes('layers'),
  45. showEta: show.includes('eta'),
  46. showFilename: show.includes('filename'),
  47. showStatus: show.includes('status'),
  48. showPrinter: show.includes('printer'),
  49. };
  50. }
  51. function getStatusText(status: PrinterStatus, t: TFunction): string {
  52. if (status.stg_cur_name) return status.stg_cur_name;
  53. switch (status.state) {
  54. case 'RUNNING': return t('streamOverlay.status.printing');
  55. case 'PAUSE': return t('streamOverlay.status.paused');
  56. case 'FINISH': return t('streamOverlay.status.finished');
  57. case 'FAILED': return t('streamOverlay.status.failed');
  58. case 'IDLE': return t('streamOverlay.status.idle');
  59. default: return status.state || t('streamOverlay.status.unknown');
  60. }
  61. }
  62. function getSizeClasses(size: OverlaySize) {
  63. switch (size) {
  64. case 'small':
  65. return {
  66. container: 'p-3',
  67. text: 'text-sm',
  68. textLarge: 'text-lg',
  69. progressHeight: 'h-2',
  70. icon: 'w-3 h-3',
  71. gap: 'gap-2',
  72. logoHeight: 'h-12',
  73. };
  74. case 'large':
  75. return {
  76. container: 'p-6',
  77. text: 'text-xl',
  78. textLarge: 'text-3xl',
  79. progressHeight: 'h-4',
  80. icon: 'w-6 h-6',
  81. gap: 'gap-4',
  82. logoHeight: 'h-24',
  83. };
  84. case 'medium':
  85. default:
  86. return {
  87. container: 'p-4',
  88. text: 'text-base',
  89. textLarge: 'text-xl',
  90. progressHeight: 'h-3',
  91. icon: 'w-4 h-4',
  92. gap: 'gap-3',
  93. logoHeight: 'h-16',
  94. };
  95. }
  96. }
  97. export function StreamOverlayPage() {
  98. const { printerId } = useParams<{ printerId: string }>();
  99. const [searchParams] = useSearchParams();
  100. const { t } = useTranslation();
  101. const queryClient = useQueryClient();
  102. const id = parseInt(printerId || '0', 10);
  103. const [imageKey, setImageKey] = useState(Date.now());
  104. const config = useMemo(() => parseConfig(searchParams), [searchParams]);
  105. const sizes = getSizeClasses(config.size);
  106. // Fetch printer info
  107. const { data: printer } = useQuery({
  108. queryKey: ['printer', id],
  109. queryFn: () => api.getPrinter(id),
  110. enabled: id > 0,
  111. });
  112. // Fetch printer status with polling
  113. const { data: status } = useQuery({
  114. queryKey: ['printerStatus', id],
  115. queryFn: () => api.getPrinterStatus(id),
  116. enabled: id > 0,
  117. refetchInterval: 2000,
  118. });
  119. // Fetch settings info
  120. const { data: settings } = useQuery({
  121. queryKey: ['settings'],
  122. queryFn: api.getSettings,
  123. });
  124. const timeFormat: TimeFormat = settings?.time_format || 'system';
  125. // WebSocket for real-time updates
  126. useEffect(() => {
  127. if (!id) return;
  128. let ws: WebSocket | null = null;
  129. let cancelled = false;
  130. // GHSA-r2qv follow-up: mint a ws-token before connecting. Uses
  131. // api.getWebSocketToken so the JWT Authorization header rides along
  132. // (raw fetch+credentials:'include' would miss it — Bambuddy uses
  133. // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
  134. // succeed even without a token.
  135. (async () => {
  136. let token: string | undefined;
  137. try {
  138. const resp = await api.getWebSocketToken();
  139. token = resp.token;
  140. } catch {
  141. // Token mint failed — auth disabled, no JWT yet, or transient
  142. // network error. Fall through; auth-disabled deployments still
  143. // succeed, auth-enabled ones close with 4401 and the page's
  144. // polling fallback continues to refresh the status.
  145. }
  146. if (cancelled) return;
  147. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  148. const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
  149. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
  150. ws = new WebSocket(wsUrl);
  151. ws.onmessage = (event) => {
  152. try {
  153. const data = JSON.parse(event.data);
  154. if (data.type === 'printer_status' && data.printer_id === id) {
  155. queryClient.setQueryData(['printerStatus', id], data.status);
  156. }
  157. } catch {
  158. // Ignore parse errors
  159. }
  160. };
  161. ws.onerror = () => {
  162. // WebSocket error - polling will continue as fallback
  163. };
  164. })();
  165. return () => {
  166. cancelled = true;
  167. if (ws) ws.close();
  168. };
  169. }, [id, queryClient]);
  170. // Update document title
  171. useEffect(() => {
  172. document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title');
  173. return () => {
  174. document.title = 'Bambuddy';
  175. };
  176. }, [printer, t]);
  177. // Refresh stream on error
  178. const handleStreamError = () => {
  179. setTimeout(() => {
  180. setImageKey(Date.now());
  181. }, 3000);
  182. };
  183. if (!id) {
  184. return (
  185. <div className="min-h-screen bg-black flex items-center justify-center">
  186. <p className="text-white">{t('streamOverlay.invalidPrinterId')}</p>
  187. </div>
  188. );
  189. }
  190. if (!status) {
  191. return (
  192. <div className="min-h-screen bg-black flex items-center justify-center">
  193. <p className="text-gray-400">{t('common.loading')}</p>
  194. </div>
  195. );
  196. }
  197. const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
  198. const progress = status.progress || 0;
  199. const streamUrl = withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`);
  200. return (
  201. <div className="min-h-screen bg-black relative overflow-hidden">
  202. {/* Camera feed - fullscreen background (optional) */}
  203. {config.showCamera && (
  204. <img
  205. key={imageKey}
  206. src={streamUrl}
  207. alt={t('streamOverlay.cameraStream')}
  208. className="absolute inset-0 w-full h-full object-contain"
  209. style={printer?.camera_rotation ? { transform: `rotate(${printer.camera_rotation}deg)` } : undefined}
  210. onError={handleStreamError}
  211. />
  212. )}
  213. {/* Bambuddy logo - top right */}
  214. <a
  215. href="https://github.com/maziggy/bambuddy"
  216. target="_blank"
  217. rel="noopener noreferrer"
  218. className="absolute top-4 right-4 z-10"
  219. >
  220. <img
  221. src="/img/bambuddy_logo_dark_transparent.png"
  222. alt="Bambuddy"
  223. className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
  224. />
  225. </a>
  226. {/* Status overlay - bottom */}
  227. <div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
  228. <div className={`${sizes.container}`}>
  229. {/* Printer name */}
  230. {config.showPrinter && printer && (
  231. <div className={`flex items-center ${sizes.gap} mb-2`}>
  232. <Printer className={`${sizes.icon} text-white/70`} />
  233. <span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
  234. </div>
  235. )}
  236. {/* Filename */}
  237. {config.showFilename && status.current_print && (
  238. <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
  239. {formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t)}
  240. </div>
  241. )}
  242. {/* Status text */}
  243. {config.showStatus && (
  244. <div className={`${sizes.text} text-white/70 mb-2`}>
  245. {getStatusText(status, t)}
  246. </div>
  247. )}
  248. {/* Progress bar */}
  249. {config.showProgress && isPrinting && (
  250. <div className="mb-3">
  251. <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
  252. <span className="text-white/70">{t('streamOverlay.progress')}</span>
  253. <span className="text-white font-bold">{Math.round(progress)}%</span>
  254. </div>
  255. <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
  256. <div
  257. className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
  258. style={{ width: `${progress}%` }}
  259. />
  260. </div>
  261. </div>
  262. )}
  263. {/* Stats row */}
  264. {isPrinting && (config.showLayers || config.showEta) && (
  265. <div className={`flex items-center ${sizes.gap} flex-wrap`}>
  266. {/* Layers */}
  267. {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
  268. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  269. <Layers className={sizes.icon} />
  270. <span className={sizes.text}>
  271. <span className="text-white">{status.layer_num}</span>
  272. <span className="mx-1">/</span>
  273. <span>{status.total_layers}</span>
  274. </span>
  275. </div>
  276. )}
  277. {/* Remaining time */}
  278. {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
  279. <>
  280. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  281. <Timer className={sizes.icon} />
  282. <span className={`${sizes.text} text-white`}>
  283. {formatDuration(status.remaining_time * 60)}
  284. </span>
  285. </div>
  286. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  287. <Clock className={sizes.icon} />
  288. <span className={`${sizes.text} text-white`}>
  289. {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
  290. </span>
  291. </div>
  292. </>
  293. )}
  294. </div>
  295. )}
  296. {/* Idle state */}
  297. {!isPrinting && (
  298. <div className={`${sizes.text} text-white/70 py-2`}>
  299. {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
  300. </div>
  301. )}
  302. </div>
  303. </div>
  304. </div>
  305. );
  306. }