StreamOverlayPage.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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, Flame, Square, Box } from 'lucide-react';
  6. import { api, ApiError, withStreamToken } from '../api/client';
  7. import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
  8. import { mapModelCode } from '../utils/printerModel';
  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. showModel: boolean;
  22. showNozzle: boolean;
  23. showBed: boolean;
  24. showChamber: boolean;
  25. }
  26. function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
  27. if (!name) return '';
  28. if (!gcodeFile) return name;
  29. const match = gcodeFile.match(/plate_(\d+)\.gcode/);
  30. if (match && parseInt(match[1], 10) > 1) {
  31. return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`;
  32. }
  33. return name;
  34. }
  35. function parseConfig(params: URLSearchParams): OverlayConfig {
  36. // The default set is deliberately unchanged by #1422: temperatures are opt-in,
  37. // so every overlay URL already pasted into an OBS scene keeps looking the same
  38. // after upgrading.
  39. const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
  40. // Parse FPS (default 15, max 30, min 1)
  41. const fpsParam = parseInt(params.get('fps') || '15', 10);
  42. const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
  43. // Parse camera toggle (default true, set camera=false to hide)
  44. const cameraParam = params.get('camera');
  45. const showCamera = cameraParam !== 'false' && cameraParam !== '0';
  46. return {
  47. size: (params.get('size') as OverlaySize) || 'medium',
  48. fps,
  49. showCamera,
  50. showProgress: show.includes('progress'),
  51. showLayers: show.includes('layers'),
  52. showEta: show.includes('eta'),
  53. showFilename: show.includes('filename'),
  54. showStatus: show.includes('status'),
  55. showPrinter: show.includes('printer'),
  56. showModel: show.includes('model'),
  57. showNozzle: show.includes('nozzle'),
  58. showBed: show.includes('bed'),
  59. showChamber: show.includes('chamber'),
  60. };
  61. }
  62. // Accepts the minimal shape shared by PrinterStatus (logged-in path) and the
  63. // token-authed OverlayStatus (kiosk path) — both carry state + stg_cur_name.
  64. function getStatusText(status: { state: string | null; stg_cur_name?: string | null }, t: TFunction): string {
  65. if (status.stg_cur_name) return status.stg_cur_name;
  66. switch (status.state) {
  67. case 'RUNNING': return t('streamOverlay.status.printing');
  68. case 'PAUSE': return t('streamOverlay.status.paused');
  69. case 'FINISH': return t('streamOverlay.status.finished');
  70. case 'FAILED': return t('streamOverlay.status.failed');
  71. case 'IDLE': return t('streamOverlay.status.idle');
  72. default: return status.state || t('streamOverlay.status.unknown');
  73. }
  74. }
  75. // Reads one reading out of either status shape. The kiosk feed types
  76. // temperatures as Record<string, number>; the logged-in PrinterStatus types it
  77. // as a named object that also carries `*_heating` booleans. Narrowing here lets
  78. // one render path serve both without casting.
  79. function readTemp(temps: Record<string, unknown>, key: string): number | null {
  80. const value = temps[key];
  81. return typeof value === 'number' ? value : null;
  82. }
  83. interface TempReadingProps {
  84. icon: React.ReactNode;
  85. label: string;
  86. current: number;
  87. target: number | null;
  88. sizes: ReturnType<typeof getSizeClasses>;
  89. }
  90. // One "Nozzle 220°C" reading. The target is appended only while it is set and
  91. // still differs from the current value, so a hotend that has reached
  92. // temperature reads "220°C" for the rest of the print instead of the noisier
  93. // "220 / 220°C".
  94. function TempReading({ icon, label, current, target, sizes }: TempReadingProps) {
  95. const heating = target != null && target > 0 && Math.round(target) !== Math.round(current);
  96. return (
  97. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  98. {icon}
  99. <span className={sizes.text}>
  100. <span className="mr-1">{label}</span>
  101. <span className="text-white">{Math.round(current)}°C</span>
  102. {heating && (
  103. <>
  104. <span className="mx-1">/</span>
  105. <span>{Math.round(target)}°C</span>
  106. </>
  107. )}
  108. </span>
  109. </div>
  110. );
  111. }
  112. function getSizeClasses(size: OverlaySize) {
  113. switch (size) {
  114. case 'small':
  115. return {
  116. container: 'p-3',
  117. text: 'text-sm',
  118. textLarge: 'text-lg',
  119. progressHeight: 'h-2',
  120. icon: 'w-3 h-3',
  121. gap: 'gap-2',
  122. logoHeight: 'h-12',
  123. };
  124. case 'large':
  125. return {
  126. container: 'p-6',
  127. text: 'text-xl',
  128. textLarge: 'text-3xl',
  129. progressHeight: 'h-4',
  130. icon: 'w-6 h-6',
  131. gap: 'gap-4',
  132. logoHeight: 'h-24',
  133. };
  134. case 'medium':
  135. default:
  136. return {
  137. container: 'p-4',
  138. text: 'text-base',
  139. textLarge: 'text-xl',
  140. progressHeight: 'h-3',
  141. icon: 'w-4 h-4',
  142. gap: 'gap-3',
  143. logoHeight: 'h-16',
  144. };
  145. }
  146. }
  147. export function StreamOverlayPage() {
  148. const { printerId } = useParams<{ printerId: string }>();
  149. const [searchParams] = useSearchParams();
  150. const { t } = useTranslation();
  151. const queryClient = useQueryClient();
  152. const id = parseInt(printerId || '0', 10);
  153. const [imageKey, setImageKey] = useState(Date.now());
  154. const config = useMemo(() => parseConfig(searchParams), [searchParams]);
  155. const sizes = getSizeClasses(config.size);
  156. // Kiosk mode (#2613): OBS and other embeds have no login session, so they
  157. // pass an `overlay`-scoped token in the URL. When present, every data call
  158. // (status + camera stream) is authenticated by that token instead of a JWT.
  159. const token = searchParams.get('token');
  160. const kiosk = token != null && token !== '';
  161. // Kiosk path: one token-authenticated call for name + live status + the one
  162. // setting the overlay reads. No JWT, so this is the only feed available.
  163. const { data: overlay } = useQuery({
  164. queryKey: ['overlayStatus', id, token],
  165. queryFn: () => api.getOverlayStatus(id, token ?? undefined),
  166. enabled: id > 0 && kiosk,
  167. refetchInterval: 2000,
  168. });
  169. // Logged-in path: the ordinary JWT-authenticated queries, unchanged. Disabled
  170. // in kiosk mode so an unauthenticated OBS browser never fires a doomed 401.
  171. const { data: printerData } = useQuery({
  172. queryKey: ['printer', id],
  173. queryFn: () => api.getPrinter(id),
  174. enabled: id > 0 && !kiosk,
  175. });
  176. const { data: statusData } = useQuery({
  177. queryKey: ['printerStatus', id],
  178. queryFn: () => api.getPrinterStatus(id),
  179. enabled: id > 0 && !kiosk,
  180. refetchInterval: 2000,
  181. });
  182. const { data: settings } = useQuery({
  183. queryKey: ['settings'],
  184. queryFn: api.getSettings,
  185. enabled: !kiosk,
  186. });
  187. // Normalize the two sources into the shape the render below reads. Memoized
  188. // because the title effect depends on `printer` — a fresh object literal each
  189. // render would re-run it (and reset document.title) on every poll tick.
  190. const printer = useMemo(
  191. () =>
  192. kiosk
  193. ? overlay && { name: overlay.name, model: overlay.model, camera_rotation: overlay.camera_rotation }
  194. : printerData,
  195. [kiosk, overlay, printerData],
  196. );
  197. const printerIdentity = [
  198. config.showPrinter ? printer?.name : null,
  199. config.showModel ? mapModelCode(printer?.model ?? null) : null,
  200. ].filter(Boolean).join(' · ');
  201. const status = kiosk ? overlay : statusData;
  202. const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
  203. // WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
  204. // where the token can't mint a ws-token — the 2s poll above is the feed).
  205. useEffect(() => {
  206. if (!id || kiosk) return;
  207. let ws: WebSocket | null = null;
  208. let cancelled = false;
  209. // GHSA-r2qv follow-up: mint a ws-token before connecting. Uses
  210. // api.getWebSocketToken so the JWT Authorization header rides along
  211. // (raw fetch+credentials:'include' would miss it — Bambuddy uses
  212. // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
  213. // succeed even without a token.
  214. (async () => {
  215. let wsToken: string | undefined;
  216. try {
  217. const resp = await api.getWebSocketToken();
  218. wsToken = resp.token;
  219. } catch (err) {
  220. // A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an
  221. // auth decision — a tokenless socket would just be closed 4401, so
  222. // skip opening one and let the REST polling fallback keep the overlay
  223. // fresh. There's no reconnect loop on this page, so this is purely
  224. // avoiding one doomed socket per mount. A network/5xx error is not
  225. // auth: fall through and try anyway (auth-disabled deployments land
  226. // here with no token and connect fine).
  227. const status = err instanceof ApiError ? err.status : 0;
  228. if (status === 401 || status === 403) return;
  229. }
  230. if (cancelled) return;
  231. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  232. const tokenParam = wsToken ? `?token=${encodeURIComponent(wsToken)}` : '';
  233. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
  234. ws = new WebSocket(wsUrl);
  235. ws.onmessage = (event) => {
  236. try {
  237. const data = JSON.parse(event.data);
  238. if (data.type === 'printer_status' && data.printer_id === id) {
  239. queryClient.setQueryData(['printerStatus', id], data.status);
  240. }
  241. } catch {
  242. // Ignore parse errors
  243. }
  244. };
  245. ws.onerror = () => {
  246. // WebSocket error - polling will continue as fallback
  247. };
  248. })();
  249. return () => {
  250. cancelled = true;
  251. if (ws) ws.close();
  252. };
  253. }, [id, kiosk, queryClient]);
  254. // Update document title
  255. useEffect(() => {
  256. document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title');
  257. return () => {
  258. document.title = 'Bambuddy';
  259. };
  260. }, [printer, t]);
  261. // Refresh stream on error
  262. const handleStreamError = () => {
  263. setTimeout(() => {
  264. setImageKey(Date.now());
  265. }, 3000);
  266. };
  267. if (!id) {
  268. return (
  269. <div className="min-h-screen bg-black flex items-center justify-center">
  270. <p className="text-white">{t('streamOverlay.invalidPrinterId')}</p>
  271. </div>
  272. );
  273. }
  274. if (!status) {
  275. return (
  276. <div className="min-h-screen bg-black flex items-center justify-center">
  277. <p className="text-gray-400">{t('common.loading')}</p>
  278. </div>
  279. );
  280. }
  281. const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
  282. const progress = status.progress || 0;
  283. // Temperature readings the URL asked for, in a fixed order, skipping any the
  284. // printer doesn't report. Labels reuse printers.heaterHistory.* so the naming
  285. // matches the heater chart rather than inventing a second vocabulary.
  286. const temps: Record<string, unknown> = status.temperatures ?? {};
  287. const tempReadings: {
  288. key: string;
  289. icon: React.ReactNode;
  290. label: string;
  291. current: number;
  292. target: number | null;
  293. }[] = [];
  294. if (config.showNozzle) {
  295. const nozzle = readTemp(temps, 'nozzle');
  296. const nozzle2 = readTemp(temps, 'nozzle_2');
  297. if (nozzle != null) {
  298. tempReadings.push({
  299. key: 'nozzle',
  300. icon: <Flame className={sizes.icon} />,
  301. label: t('printers.heaterHistory.nozzle', 'Nozzle'),
  302. current: nozzle,
  303. target: readTemp(temps, 'nozzle_target'),
  304. });
  305. }
  306. if (nozzle2 != null) {
  307. tempReadings.push({
  308. key: 'nozzle_2',
  309. icon: <Flame className={sizes.icon} />,
  310. label: t('printers.heaterHistory.nozzle2', 'Nozzle 2'),
  311. current: nozzle2,
  312. target: readTemp(temps, 'nozzle_2_target'),
  313. });
  314. }
  315. }
  316. if (config.showBed) {
  317. const bed = readTemp(temps, 'bed');
  318. if (bed != null) {
  319. tempReadings.push({
  320. key: 'bed',
  321. icon: <Square className={sizes.icon} />,
  322. label: t('printers.heaterHistory.bed', 'Bed'),
  323. current: bed,
  324. target: readTemp(temps, 'bed_target'),
  325. });
  326. }
  327. }
  328. if (config.showChamber) {
  329. const chamber = readTemp(temps, 'chamber');
  330. if (chamber != null) {
  331. tempReadings.push({
  332. key: 'chamber',
  333. icon: <Box className={sizes.icon} />,
  334. label: t('printers.heaterHistory.chamber', 'Chamber'),
  335. current: chamber,
  336. target: readTemp(temps, 'chamber_target'),
  337. });
  338. }
  339. }
  340. // Append the kiosk token directly rather than leaning on withStreamToken's
  341. // module cache — the cache is populated by an effect and would miss the first
  342. // render (a 401 flash before the retry). The logged-in path keeps the cache.
  343. const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
  344. const streamUrl = kiosk && token
  345. ? `${camPath}&token=${encodeURIComponent(token)}`
  346. : withStreamToken(camPath);
  347. return (
  348. <div className="min-h-screen bg-black relative overflow-hidden">
  349. {/* Camera feed - fullscreen background (optional) */}
  350. {config.showCamera && (
  351. <img
  352. key={imageKey}
  353. src={streamUrl}
  354. alt={t('streamOverlay.cameraStream')}
  355. className="absolute inset-0 w-full h-full object-contain"
  356. style={printer?.camera_rotation ? { transform: `rotate(${printer.camera_rotation}deg)` } : undefined}
  357. onError={handleStreamError}
  358. />
  359. )}
  360. {/* Bambuddy logo - top right */}
  361. <a
  362. href="https://github.com/maziggy/bambuddy"
  363. target="_blank"
  364. rel="noopener noreferrer"
  365. className="absolute top-4 right-4 z-10"
  366. >
  367. <img
  368. src="/img/bambuddy_logo_dark_transparent.png"
  369. alt="Bambuddy"
  370. className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
  371. />
  372. </a>
  373. {/* Status overlay - bottom */}
  374. <div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
  375. <div className={`${sizes.container}`}>
  376. {/* Printer name and model can each be selected independently. */}
  377. {printerIdentity && (
  378. <div className={`flex items-center ${sizes.gap} mb-2`}>
  379. <Printer className={`${sizes.icon} shrink-0 text-white/70`} />
  380. <span className={`${sizes.text} min-w-0 truncate text-white font-medium`}>{printerIdentity}</span>
  381. </div>
  382. )}
  383. {/* Filename */}
  384. {config.showFilename && status.current_print && (
  385. <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
  386. {formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t)}
  387. </div>
  388. )}
  389. {/* Status text */}
  390. {config.showStatus && (
  391. <div className={`${sizes.text} text-white/70 mb-2`}>
  392. {getStatusText(status, t)}
  393. </div>
  394. )}
  395. {/* Progress bar */}
  396. {config.showProgress && isPrinting && (
  397. <div className="mb-3">
  398. <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
  399. <span className="text-white/70">{t('streamOverlay.progress')}</span>
  400. <span className="text-white font-bold">{Math.round(progress)}%</span>
  401. </div>
  402. <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
  403. <div
  404. className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
  405. style={{ width: `${progress}%` }}
  406. />
  407. </div>
  408. </div>
  409. )}
  410. {/* Stats row */}
  411. {isPrinting && (config.showLayers || config.showEta) && (
  412. <div className={`flex items-center ${sizes.gap} flex-wrap`}>
  413. {/* Layers */}
  414. {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
  415. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  416. <Layers className={sizes.icon} />
  417. <span className={sizes.text}>
  418. <span className="text-white">{status.layer_num}</span>
  419. <span className="mx-1">/</span>
  420. <span>{status.total_layers}</span>
  421. </span>
  422. </div>
  423. )}
  424. {/* Remaining time */}
  425. {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
  426. <>
  427. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  428. <Timer className={sizes.icon} />
  429. <span className={`${sizes.text} text-white`}>
  430. {formatDuration(status.remaining_time * 60)}
  431. </span>
  432. </div>
  433. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  434. <Clock className={sizes.icon} />
  435. <span className={`${sizes.text} text-white`}>
  436. {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
  437. </span>
  438. </div>
  439. </>
  440. )}
  441. </div>
  442. )}
  443. {/* Idle state */}
  444. {!isPrinting && (
  445. <div className={`${sizes.text} text-white/70 py-2`}>
  446. {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
  447. </div>
  448. )}
  449. {/* Temperatures (#1422). Rendered whether or not a print is running —
  450. a preheating or cooling printer is exactly when these are worth
  451. watching. Each reading appears only when the printer reports it,
  452. so a single-nozzle machine shows one nozzle and a model without a
  453. chamber sensor shows no chamber row even if `chamber` is in
  454. ?show= (the backend omits the reading entirely for those). */}
  455. {tempReadings.length > 0 && (
  456. <div className={`flex items-center ${sizes.gap} flex-wrap mt-2`}>
  457. {tempReadings.map((reading) => (
  458. <TempReading
  459. key={reading.key}
  460. icon={reading.icon}
  461. label={reading.label}
  462. current={reading.current}
  463. target={reading.target}
  464. sizes={sizes}
  465. />
  466. ))}
  467. </div>
  468. )}
  469. </div>
  470. </div>
  471. </div>
  472. );
  473. }