StreamOverlayPage.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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, 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. }
  21. function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
  22. if (!name) return '';
  23. if (!gcodeFile) return name;
  24. const match = gcodeFile.match(/plate_(\d+)\.gcode/);
  25. if (match && parseInt(match[1], 10) > 1) {
  26. return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`;
  27. }
  28. return name;
  29. }
  30. function parseConfig(params: URLSearchParams): OverlayConfig {
  31. const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
  32. // Parse FPS (default 15, max 30, min 1)
  33. const fpsParam = parseInt(params.get('fps') || '15', 10);
  34. const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
  35. // Parse camera toggle (default true, set camera=false to hide)
  36. const cameraParam = params.get('camera');
  37. const showCamera = cameraParam !== 'false' && cameraParam !== '0';
  38. return {
  39. size: (params.get('size') as OverlaySize) || 'medium',
  40. fps,
  41. showCamera,
  42. showProgress: show.includes('progress'),
  43. showLayers: show.includes('layers'),
  44. showEta: show.includes('eta'),
  45. showFilename: show.includes('filename'),
  46. showStatus: show.includes('status'),
  47. showPrinter: show.includes('printer'),
  48. };
  49. }
  50. // Accepts the minimal shape shared by PrinterStatus (logged-in path) and the
  51. // token-authed OverlayStatus (kiosk path) — both carry state + stg_cur_name.
  52. function getStatusText(status: { state: string | null; stg_cur_name?: string | null }, t: TFunction): string {
  53. if (status.stg_cur_name) return status.stg_cur_name;
  54. switch (status.state) {
  55. case 'RUNNING': return t('streamOverlay.status.printing');
  56. case 'PAUSE': return t('streamOverlay.status.paused');
  57. case 'FINISH': return t('streamOverlay.status.finished');
  58. case 'FAILED': return t('streamOverlay.status.failed');
  59. case 'IDLE': return t('streamOverlay.status.idle');
  60. default: return status.state || t('streamOverlay.status.unknown');
  61. }
  62. }
  63. function getSizeClasses(size: OverlaySize) {
  64. switch (size) {
  65. case 'small':
  66. return {
  67. container: 'p-3',
  68. text: 'text-sm',
  69. textLarge: 'text-lg',
  70. progressHeight: 'h-2',
  71. icon: 'w-3 h-3',
  72. gap: 'gap-2',
  73. logoHeight: 'h-12',
  74. };
  75. case 'large':
  76. return {
  77. container: 'p-6',
  78. text: 'text-xl',
  79. textLarge: 'text-3xl',
  80. progressHeight: 'h-4',
  81. icon: 'w-6 h-6',
  82. gap: 'gap-4',
  83. logoHeight: 'h-24',
  84. };
  85. case 'medium':
  86. default:
  87. return {
  88. container: 'p-4',
  89. text: 'text-base',
  90. textLarge: 'text-xl',
  91. progressHeight: 'h-3',
  92. icon: 'w-4 h-4',
  93. gap: 'gap-3',
  94. logoHeight: 'h-16',
  95. };
  96. }
  97. }
  98. export function StreamOverlayPage() {
  99. const { printerId } = useParams<{ printerId: string }>();
  100. const [searchParams] = useSearchParams();
  101. const { t } = useTranslation();
  102. const queryClient = useQueryClient();
  103. const id = parseInt(printerId || '0', 10);
  104. const [imageKey, setImageKey] = useState(Date.now());
  105. const config = useMemo(() => parseConfig(searchParams), [searchParams]);
  106. const sizes = getSizeClasses(config.size);
  107. // Kiosk mode (#2613): OBS and other embeds have no login session, so they
  108. // pass an `overlay`-scoped token in the URL. When present, every data call
  109. // (status + camera stream) is authenticated by that token instead of a JWT.
  110. const token = searchParams.get('token');
  111. const kiosk = token != null && token !== '';
  112. // Kiosk path: one token-authenticated call for name + live status + the one
  113. // setting the overlay reads. No JWT, so this is the only feed available.
  114. const { data: overlay } = useQuery({
  115. queryKey: ['overlayStatus', id, token],
  116. queryFn: () => api.getOverlayStatus(id, token ?? undefined),
  117. enabled: id > 0 && kiosk,
  118. refetchInterval: 2000,
  119. });
  120. // Logged-in path: the ordinary JWT-authenticated queries, unchanged. Disabled
  121. // in kiosk mode so an unauthenticated OBS browser never fires a doomed 401.
  122. const { data: printerData } = useQuery({
  123. queryKey: ['printer', id],
  124. queryFn: () => api.getPrinter(id),
  125. enabled: id > 0 && !kiosk,
  126. });
  127. const { data: statusData } = useQuery({
  128. queryKey: ['printerStatus', id],
  129. queryFn: () => api.getPrinterStatus(id),
  130. enabled: id > 0 && !kiosk,
  131. refetchInterval: 2000,
  132. });
  133. const { data: settings } = useQuery({
  134. queryKey: ['settings'],
  135. queryFn: api.getSettings,
  136. enabled: !kiosk,
  137. });
  138. // Normalize the two sources into the shape the render below reads. Memoized
  139. // because the title effect depends on `printer` — a fresh object literal each
  140. // render would re-run it (and reset document.title) on every poll tick.
  141. const printer = useMemo(
  142. () =>
  143. kiosk
  144. ? overlay && { name: overlay.name, camera_rotation: overlay.camera_rotation }
  145. : printerData,
  146. [kiosk, overlay, printerData],
  147. );
  148. const status = kiosk ? overlay : statusData;
  149. const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
  150. // WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
  151. // where the token can't mint a ws-token — the 2s poll above is the feed).
  152. useEffect(() => {
  153. if (!id || kiosk) return;
  154. let ws: WebSocket | null = null;
  155. let cancelled = false;
  156. // GHSA-r2qv follow-up: mint a ws-token before connecting. Uses
  157. // api.getWebSocketToken so the JWT Authorization header rides along
  158. // (raw fetch+credentials:'include' would miss it — Bambuddy uses
  159. // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
  160. // succeed even without a token.
  161. (async () => {
  162. let wsToken: string | undefined;
  163. try {
  164. const resp = await api.getWebSocketToken();
  165. wsToken = resp.token;
  166. } catch (err) {
  167. // A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an
  168. // auth decision — a tokenless socket would just be closed 4401, so
  169. // skip opening one and let the REST polling fallback keep the overlay
  170. // fresh. There's no reconnect loop on this page, so this is purely
  171. // avoiding one doomed socket per mount. A network/5xx error is not
  172. // auth: fall through and try anyway (auth-disabled deployments land
  173. // here with no token and connect fine).
  174. const status = err instanceof ApiError ? err.status : 0;
  175. if (status === 401 || status === 403) return;
  176. }
  177. if (cancelled) return;
  178. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  179. const tokenParam = wsToken ? `?token=${encodeURIComponent(wsToken)}` : '';
  180. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
  181. ws = new WebSocket(wsUrl);
  182. ws.onmessage = (event) => {
  183. try {
  184. const data = JSON.parse(event.data);
  185. if (data.type === 'printer_status' && data.printer_id === id) {
  186. queryClient.setQueryData(['printerStatus', id], data.status);
  187. }
  188. } catch {
  189. // Ignore parse errors
  190. }
  191. };
  192. ws.onerror = () => {
  193. // WebSocket error - polling will continue as fallback
  194. };
  195. })();
  196. return () => {
  197. cancelled = true;
  198. if (ws) ws.close();
  199. };
  200. }, [id, kiosk, queryClient]);
  201. // Update document title
  202. useEffect(() => {
  203. document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title');
  204. return () => {
  205. document.title = 'Bambuddy';
  206. };
  207. }, [printer, t]);
  208. // Refresh stream on error
  209. const handleStreamError = () => {
  210. setTimeout(() => {
  211. setImageKey(Date.now());
  212. }, 3000);
  213. };
  214. if (!id) {
  215. return (
  216. <div className="min-h-screen bg-black flex items-center justify-center">
  217. <p className="text-white">{t('streamOverlay.invalidPrinterId')}</p>
  218. </div>
  219. );
  220. }
  221. if (!status) {
  222. return (
  223. <div className="min-h-screen bg-black flex items-center justify-center">
  224. <p className="text-gray-400">{t('common.loading')}</p>
  225. </div>
  226. );
  227. }
  228. const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
  229. const progress = status.progress || 0;
  230. // Append the kiosk token directly rather than leaning on withStreamToken's
  231. // module cache — the cache is populated by an effect and would miss the first
  232. // render (a 401 flash before the retry). The logged-in path keeps the cache.
  233. const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
  234. const streamUrl = kiosk && token
  235. ? `${camPath}&token=${encodeURIComponent(token)}`
  236. : withStreamToken(camPath);
  237. return (
  238. <div className="min-h-screen bg-black relative overflow-hidden">
  239. {/* Camera feed - fullscreen background (optional) */}
  240. {config.showCamera && (
  241. <img
  242. key={imageKey}
  243. src={streamUrl}
  244. alt={t('streamOverlay.cameraStream')}
  245. className="absolute inset-0 w-full h-full object-contain"
  246. style={printer?.camera_rotation ? { transform: `rotate(${printer.camera_rotation}deg)` } : undefined}
  247. onError={handleStreamError}
  248. />
  249. )}
  250. {/* Bambuddy logo - top right */}
  251. <a
  252. href="https://github.com/maziggy/bambuddy"
  253. target="_blank"
  254. rel="noopener noreferrer"
  255. className="absolute top-4 right-4 z-10"
  256. >
  257. <img
  258. src="/img/bambuddy_logo_dark_transparent.png"
  259. alt="Bambuddy"
  260. className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
  261. />
  262. </a>
  263. {/* Status overlay - bottom */}
  264. <div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
  265. <div className={`${sizes.container}`}>
  266. {/* Printer name */}
  267. {config.showPrinter && printer && (
  268. <div className={`flex items-center ${sizes.gap} mb-2`}>
  269. <Printer className={`${sizes.icon} text-white/70`} />
  270. <span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
  271. </div>
  272. )}
  273. {/* Filename */}
  274. {config.showFilename && status.current_print && (
  275. <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
  276. {formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t)}
  277. </div>
  278. )}
  279. {/* Status text */}
  280. {config.showStatus && (
  281. <div className={`${sizes.text} text-white/70 mb-2`}>
  282. {getStatusText(status, t)}
  283. </div>
  284. )}
  285. {/* Progress bar */}
  286. {config.showProgress && isPrinting && (
  287. <div className="mb-3">
  288. <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
  289. <span className="text-white/70">{t('streamOverlay.progress')}</span>
  290. <span className="text-white font-bold">{Math.round(progress)}%</span>
  291. </div>
  292. <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
  293. <div
  294. className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
  295. style={{ width: `${progress}%` }}
  296. />
  297. </div>
  298. </div>
  299. )}
  300. {/* Stats row */}
  301. {isPrinting && (config.showLayers || config.showEta) && (
  302. <div className={`flex items-center ${sizes.gap} flex-wrap`}>
  303. {/* Layers */}
  304. {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
  305. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  306. <Layers className={sizes.icon} />
  307. <span className={sizes.text}>
  308. <span className="text-white">{status.layer_num}</span>
  309. <span className="mx-1">/</span>
  310. <span>{status.total_layers}</span>
  311. </span>
  312. </div>
  313. )}
  314. {/* Remaining time */}
  315. {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
  316. <>
  317. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  318. <Timer className={sizes.icon} />
  319. <span className={`${sizes.text} text-white`}>
  320. {formatDuration(status.remaining_time * 60)}
  321. </span>
  322. </div>
  323. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  324. <Clock className={sizes.icon} />
  325. <span className={`${sizes.text} text-white`}>
  326. {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
  327. </span>
  328. </div>
  329. </>
  330. )}
  331. </div>
  332. )}
  333. {/* Idle state */}
  334. {!isPrinting && (
  335. <div className={`${sizes.text} text-white/70 py-2`}>
  336. {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
  337. </div>
  338. )}
  339. </div>
  340. </div>
  341. </div>
  342. );
  343. }