Streaming overlay page for OBS integration (#164)

Add a dedicated overlay page at /overlay/:printerId that combines
camera feed with real-time print status for live streaming use cases.

Features:
- Camera feed as fullscreen background
- Status overlay at bottom with gradient for readability
- Real-time updates via WebSocket with polling fallback
- Customizable via query params (?size=large&show=progress,eta)

Closes #164
This commit is contained in:
maziggy
2026-01-30 11:05:20 +01:00
parent bced6415e0
commit 534f2dcc91
9 changed files with 670 additions and 119 deletions
+8
View File
@@ -5,6 +5,14 @@ All notable changes to Bambuddy will be documented in this file.
## [0.1.6-final] - Not released
### New Features
- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
- All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
- Real-time print progress, ETA, layer count, and filename display
- Bambuddy logo branding (links to GitHub)
- Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
- No authentication required - designed for OBS browser source embedding
- Gradient overlay at bottom for readable text over camera feed
- Auto-reconnect on camera stream errors
- **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
- Prevents Bambuddy from checking Bambu Lab servers for firmware updates
- Useful for users who prefer to manage firmware manually or have network restrictions
+1
View File
@@ -58,6 +58,7 @@
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- Live camera streaming (MJPEG) & snapshots with multi-viewer support
- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
- External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
- Fan status monitoring (part cooling, auxiliary, chamber)
+4
View File
@@ -12,6 +12,7 @@ import { ProjectsPage } from './pages/ProjectsPage';
import { ProjectDetailPage } from './pages/ProjectDetailPage';
import { FileManagerPage } from './pages/FileManagerPage';
import { CameraPage } from './pages/CameraPage';
import { StreamOverlayPage } from './pages/StreamOverlayPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
@@ -109,6 +110,9 @@ function App() {
{/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
<Route path="/camera/:printerId" element={<CameraPage />} />
{/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
{/* Main app with WebSocket for real-time updates */}
<Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
<Route index element={<PrintersPage />} />
@@ -0,0 +1,230 @@
/**
* Tests for the StreamOverlayPage component.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
const mockPrinter = {
id: 1,
name: 'X1 Carbon',
ip_address: '192.168.1.100',
serial_number: '00M09A350100001',
access_code: '12345678',
model: 'X1C',
enabled: true,
};
const mockStatusIdle = {
id: 1,
name: 'X1 Carbon',
connected: true,
state: 'IDLE',
progress: 0,
current_print: null,
remaining_time: null,
layer_num: null,
total_layers: null,
stg_cur_name: null,
};
const mockStatusPrinting = {
id: 1,
name: 'X1 Carbon',
connected: true,
state: 'RUNNING',
progress: 45,
current_print: 'Benchy.gcode.3mf',
remaining_time: 82,
layer_num: 150,
total_layers: 300,
stg_cur_name: null,
};
// Custom render for StreamOverlayPage
function renderOverlayPage(printerId: number, queryParams = '') {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return rtlRender(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
</Routes>
</ToastProvider>
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
describe('StreamOverlayPage', () => {
const originalTitle = document.title;
beforeEach(() => {
// Mock WebSocket
vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({
close: vi.fn(),
onmessage: null,
onerror: null,
})));
server.use(
http.get('/api/v1/printers/:id', () => {
return HttpResponse.json(mockPrinter);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockStatusIdle);
})
);
});
afterEach(() => {
document.title = originalTitle;
vi.unstubAllGlobals();
});
describe('rendering', () => {
it('renders overlay page for printer', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Printer is idle')).toBeInTheDocument();
});
});
it('shows Bambuddy logo', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
});
});
it('logo links to GitHub', async () => {
renderOverlayPage(1);
await waitFor(() => {
const logo = screen.getByAltText('Bambuddy');
const link = logo.closest('a');
expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
});
});
});
describe('printing state', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockStatusPrinting);
})
);
});
it('shows filename when printing', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
});
it('shows progress percentage', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('45%')).toBeInTheDocument();
});
});
it('shows layer count', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('300')).toBeInTheDocument();
});
});
it('shows status text', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Printing')).toBeInTheDocument();
});
});
});
describe('invalid printer', () => {
it('shows invalid printer message for ID 0', async () => {
renderOverlayPage(0);
await waitFor(() => {
expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
});
});
});
describe('query parameters', () => {
it('respects size parameter', async () => {
renderOverlayPage(1, '?size=large');
await waitFor(() => {
// Just verify it renders without error
expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
});
});
it('respects show parameter to hide elements', async () => {
server.use(
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockStatusPrinting);
})
);
renderOverlayPage(1, '?show=progress');
await waitFor(() => {
// Progress should be visible
expect(screen.getByText('45%')).toBeInTheDocument();
// Status text should be hidden when not in show list
expect(screen.queryByText('Printing')).not.toBeInTheDocument();
});
});
});
describe('offline state', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({
...mockStatusIdle,
connected: false,
});
})
);
});
it('shows offline message when printer disconnected', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Printer offline')).toBeInTheDocument();
});
});
});
});
+308
View File
@@ -0,0 +1,308 @@
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;
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'];
return {
size: (params.get('size') as OverlaySize) || 'medium',
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 (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-white">Invalid printer ID</p>
</div>
);
}
if (!status) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-gray-400">Loading...</p>
</div>
);
}
const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
const progress = status.progress || 0;
const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
return (
<div className="min-h-screen bg-black relative overflow-hidden">
{/* Camera feed - fullscreen background */}
<img
key={imageKey}
src={streamUrl}
alt="Camera stream"
className="absolute inset-0 w-full h-full object-contain"
onError={handleStreamError}
/>
{/* Bambuddy logo - top right */}
<a
href="https://github.com/maziggy/bambuddy"
target="_blank"
rel="noopener noreferrer"
className="absolute top-4 right-4 z-10"
>
<img
src="/img/bambuddy_logo_dark_transparent.png"
alt="Bambuddy"
className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
/>
</a>
{/* Status overlay - bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
<div className={`${sizes.container}`}>
{/* Printer name */}
{config.showPrinter && printer && (
<div className={`flex items-center ${sizes.gap} mb-2`}>
<Printer className={`${sizes.icon} text-white/70`} />
<span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
</div>
)}
{/* Filename */}
{config.showFilename && status.current_print && (
<div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
{status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
</div>
)}
{/* Status text */}
{config.showStatus && (
<div className={`${sizes.text} text-white/70 mb-2`}>
{getStatusText(status)}
</div>
)}
{/* Progress bar */}
{config.showProgress && isPrinting && (
<div className="mb-3">
<div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
<span className="text-white/70">Progress</span>
<span className="text-white font-bold">{Math.round(progress)}%</span>
</div>
<div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
<div
className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Stats row */}
{isPrinting && (config.showLayers || config.showEta) && (
<div className={`flex items-center ${sizes.gap} flex-wrap`}>
{/* Layers */}
{config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Layers className={sizes.icon} />
<span className={sizes.text}>
<span className="text-white">{status.layer_num}</span>
<span className="mx-1">/</span>
<span>{status.total_layers}</span>
</span>
</div>
)}
{/* Remaining time */}
{config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
<>
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Timer className={sizes.icon} />
<span className={`${sizes.text} text-white`}>
{formatTime(status.remaining_time * 60)}
</span>
</div>
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Clock className={sizes.icon} />
<span className={`${sizes.text} text-white`}>
ETA {formatETA(status.remaining_time)}
</span>
</div>
</>
)}
</div>
)}
{/* Idle state */}
{!isPrinting && (
<div className={`${sizes.text} text-white/70 py-2`}>
{status.connected ? 'Printer is idle' : 'Printer offline'}
</div>
)}
</div>
</div>
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CKmo1TxA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DFuUL8IF.css">
<script type="module" crossorigin src="/assets/index-wxwVsx5u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTpjfCpx.css">
</head>
<body>
<div id="root"></div>