import { useEffect, useMemo, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Layers, Clock, Timer, Printer } from 'lucide-react'; import { api } from '../api/client'; import type { PrinterStatus } from '../api/client'; 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; } function parseConfig(params: URLSearchParams): OverlayConfig { 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'), }; } function formatTime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } function formatETA(remainingMinutes: number): string { const now = new Date(); const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000); const today = new Date(); today.setHours(0, 0, 0, 0); const etaDay = new Date(eta); etaDay.setHours(0, 0, 0, 0); const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (etaDay.getTime() === today.getTime()) { return timeStr; } else if (etaDay.getTime() === today.getTime() + 86400000) { return `Tomorrow ${timeStr}`; } else { return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr; } } function getStatusText(status: PrinterStatus): string { if (status.stg_cur_name) return status.stg_cur_name; switch (status.state) { case 'RUNNING': return 'Printing'; case 'PAUSE': return 'Paused'; case 'FINISH': return 'Finished'; case 'FAILED': return 'Failed'; case 'IDLE': return 'Idle'; default: return status.state || 'Unknown'; } } 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 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); // Fetch printer info const { data: printer } = useQuery({ queryKey: ['printer', id], queryFn: () => api.getPrinter(id), enabled: id > 0, }); // Fetch printer status with polling const { data: status } = useQuery({ queryKey: ['printerStatus', id], queryFn: () => api.getPrinterStatus(id), enabled: id > 0, refetchInterval: 2000, }); // WebSocket for real-time updates useEffect(() => { if (!id) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`; const 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 () => { ws.close(); }; }, [id, queryClient]); // Update document title useEffect(() => { document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay'; return () => { document.title = 'Bambuddy'; }; }, [printer]); // Refresh stream on error const handleStreamError = () => { setTimeout(() => { setImageKey(Date.now()); }, 3000); }; if (!id) { return (

Invalid printer ID

); } if (!status) { return (

Loading...

); } const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE'; const progress = status.progress || 0; const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`; return (
{/* Camera feed - fullscreen background (optional) */} {config.showCamera && ( Camera stream )} {/* Bambuddy logo - top right */} Bambuddy {/* Status overlay - bottom */}
{/* Printer name */} {config.showPrinter && printer && (
{printer.name}
)} {/* Filename */} {config.showFilename && status.current_print && (
{status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
)} {/* Status text */} {config.showStatus && (
{getStatusText(status)}
)} {/* Progress bar */} {config.showProgress && isPrinting && (
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 && ( <>
{formatTime(status.remaining_time * 60)}
ETA {formatETA(status.remaining_time)}
)}
)} {/* Idle state */} {!isPrinting && (
{status.connected ? 'Printer is idle' : 'Printer offline'}
)}
); }