import { useEffect, useMemo, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react'; import { api, ApiError, withStreamToken } from '../api/client'; import { formatDuration, formatETA, type TimeFormat } from '../utils/date'; type TFunction = (key: string, options?: Record) => string; type OverlaySize = 'small' | 'medium' | 'large'; interface OverlayConfig { size: OverlaySize; fps: number; showCamera: boolean; showProgress: boolean; showLayers: boolean; showEta: boolean; showFilename: boolean; showStatus: boolean; showPrinter: boolean; showNozzle: boolean; showBed: boolean; showChamber: boolean; } function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record) => string): string { if (!name) return ''; if (!gcodeFile) return name; const match = gcodeFile.match(/plate_(\d+)\.gcode/); if (match && parseInt(match[1], 10) > 1) { return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`; } return name; } function parseConfig(params: URLSearchParams): OverlayConfig { // The default set is deliberately unchanged by #1422: temperatures are opt-in, // so every overlay URL already pasted into an OBS scene keeps looking the same // after upgrading. const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status']; // Parse FPS (default 15, max 30, min 1) const fpsParam = parseInt(params.get('fps') || '15', 10); const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30); // Parse camera toggle (default true, set camera=false to hide) const cameraParam = params.get('camera'); const showCamera = cameraParam !== 'false' && cameraParam !== '0'; return { size: (params.get('size') as OverlaySize) || 'medium', fps, showCamera, showProgress: show.includes('progress'), showLayers: show.includes('layers'), showEta: show.includes('eta'), showFilename: show.includes('filename'), showStatus: show.includes('status'), showPrinter: show.includes('printer'), showNozzle: show.includes('nozzle'), showBed: show.includes('bed'), showChamber: show.includes('chamber'), }; } // Accepts the minimal shape shared by PrinterStatus (logged-in path) and the // token-authed OverlayStatus (kiosk path) — both carry state + stg_cur_name. function getStatusText(status: { state: string | null; stg_cur_name?: string | null }, t: TFunction): string { if (status.stg_cur_name) return status.stg_cur_name; switch (status.state) { case 'RUNNING': return t('streamOverlay.status.printing'); case 'PAUSE': return t('streamOverlay.status.paused'); case 'FINISH': return t('streamOverlay.status.finished'); case 'FAILED': return t('streamOverlay.status.failed'); case 'IDLE': return t('streamOverlay.status.idle'); default: return status.state || t('streamOverlay.status.unknown'); } } // Reads one reading out of either status shape. The kiosk feed types // temperatures as Record; the logged-in PrinterStatus types it // as a named object that also carries `*_heating` booleans. Narrowing here lets // one render path serve both without casting. function readTemp(temps: Record, key: string): number | null { const value = temps[key]; return typeof value === 'number' ? value : null; } interface TempReadingProps { icon: React.ReactNode; label: string; current: number; target: number | null; sizes: ReturnType; } // One "Nozzle 220°C" reading. The target is appended only while it is set and // still differs from the current value, so a hotend that has reached // temperature reads "220°C" for the rest of the print instead of the noisier // "220 / 220°C". function TempReading({ icon, label, current, target, sizes }: TempReadingProps) { const heating = target != null && target > 0 && Math.round(target) !== Math.round(current); return (
{icon} {label} {Math.round(current)}°C {heating && ( <> / {Math.round(target)}°C )}
); } function getSizeClasses(size: OverlaySize) { switch (size) { case 'small': return { container: 'p-3', text: 'text-sm', textLarge: 'text-lg', progressHeight: 'h-2', icon: 'w-3 h-3', gap: 'gap-2', logoHeight: 'h-12', }; case 'large': return { container: 'p-6', text: 'text-xl', textLarge: 'text-3xl', progressHeight: 'h-4', icon: 'w-6 h-6', gap: 'gap-4', logoHeight: 'h-24', }; case 'medium': default: return { container: 'p-4', text: 'text-base', textLarge: 'text-xl', progressHeight: 'h-3', icon: 'w-4 h-4', gap: 'gap-3', logoHeight: 'h-16', }; } } export function StreamOverlayPage() { const { printerId } = useParams<{ printerId: string }>(); const [searchParams] = useSearchParams(); const { t } = useTranslation(); const queryClient = useQueryClient(); const id = parseInt(printerId || '0', 10); const [imageKey, setImageKey] = useState(Date.now()); const config = useMemo(() => parseConfig(searchParams), [searchParams]); const sizes = getSizeClasses(config.size); // Kiosk mode (#2613): OBS and other embeds have no login session, so they // pass an `overlay`-scoped token in the URL. When present, every data call // (status + camera stream) is authenticated by that token instead of a JWT. const token = searchParams.get('token'); const kiosk = token != null && token !== ''; // Kiosk path: one token-authenticated call for name + live status + the one // setting the overlay reads. No JWT, so this is the only feed available. const { data: overlay } = useQuery({ queryKey: ['overlayStatus', id, token], queryFn: () => api.getOverlayStatus(id, token ?? undefined), enabled: id > 0 && kiosk, refetchInterval: 2000, }); // Logged-in path: the ordinary JWT-authenticated queries, unchanged. Disabled // in kiosk mode so an unauthenticated OBS browser never fires a doomed 401. const { data: printerData } = useQuery({ queryKey: ['printer', id], queryFn: () => api.getPrinter(id), enabled: id > 0 && !kiosk, }); const { data: statusData } = useQuery({ queryKey: ['printerStatus', id], queryFn: () => api.getPrinterStatus(id), enabled: id > 0 && !kiosk, refetchInterval: 2000, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, enabled: !kiosk, }); // Normalize the two sources into the shape the render below reads. Memoized // because the title effect depends on `printer` — a fresh object literal each // render would re-run it (and reset document.title) on every poll tick. const printer = useMemo( () => kiosk ? overlay && { name: overlay.name, camera_rotation: overlay.camera_rotation } : printerData, [kiosk, overlay, printerData], ); const status = kiosk ? overlay : statusData; const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system'; // WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode, // where the token can't mint a ws-token — the 2s poll above is the feed). useEffect(() => { if (!id || kiosk) return; let ws: WebSocket | null = null; let cancelled = false; // GHSA-r2qv follow-up: mint a ws-token before connecting. Uses // api.getWebSocketToken so the JWT Authorization header rides along // (raw fetch+credentials:'include' would miss it — Bambuddy uses // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments // succeed even without a token. (async () => { let wsToken: string | undefined; try { const resp = await api.getWebSocketToken(); wsToken = resp.token; } catch (err) { // A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an // auth decision — a tokenless socket would just be closed 4401, so // skip opening one and let the REST polling fallback keep the overlay // fresh. There's no reconnect loop on this page, so this is purely // avoiding one doomed socket per mount. A network/5xx error is not // auth: fall through and try anyway (auth-disabled deployments land // here with no token and connect fine). const status = err instanceof ApiError ? err.status : 0; if (status === 401 || status === 403) return; } if (cancelled) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const tokenParam = wsToken ? `?token=${encodeURIComponent(wsToken)}` : ''; const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`; ws = new WebSocket(wsUrl); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'printer_status' && data.printer_id === id) { queryClient.setQueryData(['printerStatus', id], data.status); } } catch { // Ignore parse errors } }; ws.onerror = () => { // WebSocket error - polling will continue as fallback }; })(); return () => { cancelled = true; if (ws) ws.close(); }; }, [id, kiosk, queryClient]); // Update document title useEffect(() => { document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title'); return () => { document.title = 'Bambuddy'; }; }, [printer, t]); // Refresh stream on error const handleStreamError = () => { setTimeout(() => { setImageKey(Date.now()); }, 3000); }; if (!id) { return (

{t('streamOverlay.invalidPrinterId')}

); } if (!status) { return (

{t('common.loading')}

); } const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE'; const progress = status.progress || 0; // Temperature readings the URL asked for, in a fixed order, skipping any the // printer doesn't report. Labels reuse printers.heaterHistory.* so the naming // matches the heater chart rather than inventing a second vocabulary. const temps: Record = status.temperatures ?? {}; const tempReadings: { key: string; icon: React.ReactNode; label: string; current: number; target: number | null; }[] = []; if (config.showNozzle) { const nozzle = readTemp(temps, 'nozzle'); const nozzle2 = readTemp(temps, 'nozzle_2'); if (nozzle != null) { tempReadings.push({ key: 'nozzle', icon: , label: t('printers.heaterHistory.nozzle', 'Nozzle'), current: nozzle, target: readTemp(temps, 'nozzle_target'), }); } if (nozzle2 != null) { tempReadings.push({ key: 'nozzle_2', icon: , label: t('printers.heaterHistory.nozzle2', 'Nozzle 2'), current: nozzle2, target: readTemp(temps, 'nozzle_2_target'), }); } } if (config.showBed) { const bed = readTemp(temps, 'bed'); if (bed != null) { tempReadings.push({ key: 'bed', icon: , label: t('printers.heaterHistory.bed', 'Bed'), current: bed, target: readTemp(temps, 'bed_target'), }); } } if (config.showChamber) { const chamber = readTemp(temps, 'chamber'); if (chamber != null) { tempReadings.push({ key: 'chamber', icon: , label: t('printers.heaterHistory.chamber', 'Chamber'), current: chamber, target: readTemp(temps, 'chamber_target'), }); } } // Append the kiosk token directly rather than leaning on withStreamToken's // module cache — the cache is populated by an effect and would miss the first // render (a 401 flash before the retry). The logged-in path keeps the cache. const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`; const streamUrl = kiosk && token ? `${camPath}&token=${encodeURIComponent(token)}` : withStreamToken(camPath); return (
{/* Camera feed - fullscreen background (optional) */} {config.showCamera && ( {t('streamOverlay.cameraStream')} )} {/* Bambuddy logo - top right */} Bambuddy {/* Status overlay - bottom */}
{/* Printer name */} {config.showPrinter && printer && (
{printer.name}
)} {/* Filename */} {config.showFilename && status.current_print && (
{formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t)}
)} {/* Status text */} {config.showStatus && (
{getStatusText(status, t)}
)} {/* Progress bar */} {config.showProgress && isPrinting && (
{t('streamOverlay.progress')} {Math.round(progress)}%
)} {/* Stats row */} {isPrinting && (config.showLayers || config.showEta) && (
{/* Layers */} {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
{status.layer_num} / {status.total_layers}
)} {/* Remaining time */} {config.showEta && status.remaining_time != null && status.remaining_time > 0 && ( <>
{formatDuration(status.remaining_time * 60)}
{t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
)}
)} {/* Idle state */} {!isPrinting && (
{status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
)} {/* Temperatures (#1422). Rendered whether or not a print is running — a preheating or cooling printer is exactly when these are worth watching. Each reading appears only when the printer reports it, so a single-nozzle machine shows one nozzle and a model without a chamber sensor shows no chamber row even if `chamber` is in ?show= (the backend omits the reading entirely for those). */} {tempReadings.length > 0 && (
{tempReadings.map((reading) => ( ))}
)}
); }