StreamOverlayPage.tsx 24 KB

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