StreamOverlayPage.tsx 18 KB

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