feat: show live print progress in the browser tab title

Optional and off by default, toggled under Settings -> Appearance. When enabled, the browser tab shows the soonest-finishing print's percentage plus a green progress-ring favicon, updated live over the existing WebSocket. The preference is stored per-browser in localStorage.

Adds the usePrintProgressTitle hook (with tests), a ThemeContext preference, the Settings toggle, i18n strings for all locales, and a README entry.
This commit is contained in:
Chachigo
2026-07-29 13:30:25 +02:00
parent d7fe72672b
commit fe03e0ec1d
19 changed files with 324 additions and 0 deletions
+1
View File
@@ -156,6 +156,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- **Print progress in the browser tab** — optional (off by default, toggle under Settings → Appearance): shows the soonest-finishing print's percentage in the tab title and a progress-ring favicon in your theme accent colour
- Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
- **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference
- **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
+2
View File
@@ -26,6 +26,7 @@ import { SetupPage } from './pages/SetupPage';
import { NotificationsPage } from './pages/NotificationsPage';
import { GCodeViewerPage } from './pages/GCodeViewerPage';
import { useWebSocket } from './hooks/useWebSocket';
import { usePrintProgressTitle } from './hooks/usePrintProgressTitle';
import { useStreamTokenSync } from './hooks/useCameraStreamToken';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -89,6 +90,7 @@ function StreamTokenSync() {
function WebSocketProvider({ children }: { children: React.ReactNode }) {
useWebSocket();
usePrintProgressTitle();
return <>{children}</>;
}
@@ -0,0 +1,106 @@
import type { ReactNode } from 'react';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the theme pref and the API the hook reads, so the effect can be exercised
// without the real providers. `theme.value` is swapped per test.
const h = vi.hoisted(() => ({
theme: {
value: {
progressInTitle: false,
resolvedMode: 'dark',
darkAccent: 'green',
lightAccent: 'green',
} as { progressInTitle: boolean; resolvedMode: string; darkAccent: string; lightAccent: string },
},
getPrinters: vi.fn(),
getPrinterStatus: vi.fn(),
}));
vi.mock('../../contexts/ThemeContext', () => ({ useTheme: () => h.theme.value }));
vi.mock('../../api/client', () => ({
api: { getPrinters: h.getPrinters, getPrinterStatus: h.getPrinterStatus },
}));
import { pickActivePrint, usePrintProgressTitle, type ProgressStatus } from '../../hooks/usePrintProgressTitle';
const running = (progress: number, remaining_time: number | null): ProgressStatus => ({
state: 'RUNNING',
progress,
remaining_time,
});
describe('pickActivePrint', () => {
it('returns null when nothing is printing', () => {
expect(pickActivePrint([])).toBeNull();
expect(pickActivePrint([undefined])).toBeNull();
expect(pickActivePrint([{ state: 'IDLE', progress: 0, remaining_time: null }])).toBeNull();
// The real paused state is 'PAUSE', not 'PAUSED'.
expect(pickActivePrint([{ state: 'PAUSE', progress: 40, remaining_time: 10 }])).toBeNull();
});
it('ignores RUNNING prints with no progress value', () => {
expect(pickActivePrint([{ state: 'RUNNING', progress: null, remaining_time: 5 }])).toBeNull();
});
it('picks the soonest-finishing print among several running', () => {
const soonest = running(20, 12);
expect(pickActivePrint([running(80, 45), soonest, running(50, 30)])).toBe(soonest);
});
it('tie-breaks equal ETAs by highest progress', () => {
const further = running(70, 15);
expect(pickActivePrint([running(30, 15), further])).toBe(further);
});
it('treats a null remaining_time as furthest away', () => {
const withEta = running(10, 60);
expect(pickActivePrint([running(90, null), withEta])).toBe(withEta);
});
it('treats remaining_time <= 0 as unknown — a just-started print must not win', () => {
// The backend serialises "ETA not known yet" as 0 (not null). A printer that
// just started (0) must not steal the tab from one that is nearly done.
const almostDone = running(95, 180);
expect(pickActivePrint([running(2, 0), almostDone])).toBe(almostDone);
expect(pickActivePrint([running(2, -1), almostDone])).toBe(almostDone);
});
});
function wrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
describe('usePrintProgressTitle effect', () => {
beforeEach(() => {
h.getPrinters.mockReset();
h.getPrinterStatus.mockReset();
document.title = 'Bambuddy';
});
afterEach(() => cleanup());
it('is inert while the pref is off — never touches the tab title', async () => {
h.theme.value = { progressInTitle: false, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
document.title = 'Something Else';
renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
await new Promise((r) => setTimeout(r, 20));
expect(document.title).toBe('Something Else');
expect(h.getPrinters).not.toHaveBeenCalled();
});
it('shows the active print percentage in the title when enabled', async () => {
h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
h.getPrinters.mockResolvedValue([{ id: 1 }]);
h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
});
});
+15
View File
@@ -19,6 +19,9 @@ interface ThemeContextType {
lightStyle: ThemeStyle;
lightBackground: LightBackground;
lightAccent: ThemeAccent;
// Show live print progress (% + green ring favicon) in the browser tab
progressInTitle: boolean;
setProgressInTitle: (v: boolean) => void;
// Actions
toggleMode: () => void;
setMode: (mode: ThemeMode) => void;
@@ -87,6 +90,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
});
// Client-only pref (localStorage), no api.updateSettings sync — the tab
// title/favicon is per-browser behaviour. Move to server settings if it
// ever needs to follow the user across devices. Default off.
const [progressInTitle, setProgressInTitleState] = useState<boolean>(() => {
return localStorage.getItem('progress-in-title') === 'true';
});
const setProgressInTitle = (v: boolean) => {
setProgressInTitleState(v);
localStorage.setItem('progress-in-title', String(v));
};
// Sync from API once auth state is known. Same gate shape as
// useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
// settle, then only fetch when we can actually expect a 200 (auth
@@ -202,6 +216,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
resolvedMode,
darkStyle, darkBackground, darkAccent,
lightStyle, lightBackground, lightAccent,
progressInTitle, setProgressInTitle,
toggleMode, setMode,
setDarkStyle, setDarkBackground, setDarkAccent,
setLightStyle, setLightBackground, setLightAccent,
+155
View File
@@ -0,0 +1,155 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { api } from '../api/client';
import { useTheme } from '../contexts/ThemeContext';
const DEFAULT_TITLE = 'Bambuddy';
const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom)
// A remaining_time <= 0 means "ETA not known yet" (the backend defaults it to 0,
// not null), so treat it as unknown rather than "finishes now".
const eta = (t: number | null): number => (t != null && t > 0 ? t : Infinity);
// Only the fields we need — keeps pickActivePrint decoupled from the full
// PrinterStatus type so the test can pass plain objects.
export interface ProgressStatus {
state: string | null;
progress: number | null;
remaining_time: number | null;
}
/**
* Of all connected printers, pick the RUNNING print to surface in the tab:
* the one finishing soonest (smallest remaining_time), tie-broken by highest
* progress. Returns null when nothing is actively printing.
*/
export function pickActivePrint<T extends ProgressStatus>(statuses: (T | undefined)[]): T | null {
let best: T | null = null;
for (const s of statuses) {
if (!s || s.state !== 'RUNNING' || s.progress == null) continue;
if (best === null) {
best = s;
continue;
}
const sr = eta(s.remaining_time);
const br = eta(best.remaining_time);
if (sr < br || (sr === br && (s.progress ?? 0) > (best.progress ?? 0))) {
best = s;
}
}
return best;
}
// Draw a 32x32 progress ring in the current theme accent colour, return a PNG
// data URL (or null if the browser has no 2d canvas, e.g. under jsdom — the
// caller then falls back to updating the title only).
function drawProgressFavicon(pct: number): string | null {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const accent =
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
FALLBACK_ACCENT;
const cx = 16;
const cy = 16;
const r = 13;
const frac = Math.max(0, Math.min(100, pct)) / 100;
const start = -Math.PI / 2; // 12 o'clock
ctx.lineWidth = 4;
// Track
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(128,128,128,0.3)';
ctx.stroke();
// Progress arc, clockwise from the top
ctx.beginPath();
ctx.arc(cx, cy, r, start, start + frac * Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineCap = 'round';
ctx.stroke();
return canvas.toDataURL('image/png');
}
// Point the <link rel="icon"> tags at the ring (remembering originals), or
// restore them when dataUrl is null. apple-touch-icon is a different rel token
// so the `rel~="icon"` selector leaves it alone.
function setFavicon(dataUrl: string | null, originals: Map<HTMLLinkElement, string>) {
const links = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
links.forEach((link) => {
if (dataUrl) {
if (!originals.has(link)) originals.set(link, link.href);
link.href = dataUrl;
} else {
const orig = originals.get(link);
if (orig !== undefined) link.href = orig;
}
});
if (!dataUrl) originals.clear();
}
/**
* When the "progress in tab" preference is on, reflect the soonest-finishing
* print's percentage in document.title and draw a progress ring favicon in the
* theme accent colour. Stays fully inert until enabled, and hands the tab back
* to its defaults once disabled, idle, or unmounted.
* Mounted once, globally, inside WebSocketProvider.
*/
export function usePrintProgressTitle() {
const { progressInTitle, resolvedMode, darkAccent, lightAccent } = useTheme();
// Re-draw the ring when the active accent changes.
const accent = resolvedMode === 'dark' ? darkAccent : lightAccent;
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
enabled: progressInTitle,
});
const statusQueries = useQueries({
queries: (progressInTitle ? printers ?? [] : []).map((p) => ({
queryKey: ['printerStatus', p.id],
queryFn: () => api.getPrinterStatus(p.id),
refetchInterval: 30000, // fallback; WebSocket drives live updates
})),
});
const originalsRef = useRef<Map<HTMLLinkElement, string>>(new Map());
// Whether we currently own the tab title/favicon. Lets us stay inert while
// off (never touch the tab) yet still restore once if we ever took it over.
const ownsRef = useRef(false);
const active = progressInTitle ? pickActivePrint(statusQueries.map((q) => q.data)) : null;
const pct = active && active.progress != null ? Math.round(active.progress) : null;
useEffect(() => {
if (progressInTitle && pct != null) {
document.title = `${pct}% · ${DEFAULT_TITLE}`;
setFavicon(drawProgressFavicon(pct), originalsRef.current);
ownsRef.current = true;
} else if (ownsRef.current) {
// Disabled or idle after having taken over — hand the tab back.
document.title = DEFAULT_TITLE;
setFavicon(null, originalsRef.current);
ownsRef.current = false;
}
// else: never owned the tab → leave it entirely alone.
}, [progressInTitle, pct, accent]);
// Restore the tab to defaults on unmount, but only if we own it.
useEffect(() => {
const originals = originalsRef.current;
const owns = ownsRef;
return () => {
if (owns.current) {
document.title = DEFAULT_TITLE;
setFavicon(null, originals);
}
};
}, []);
}
+2
View File
@@ -2436,6 +2436,8 @@ export default {
styleGlow: 'Leuchtend',
styleVibrant: 'Lebendig',
themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
progressInTitle: 'Druckfortschritt im Tab',
progressInTitleDescription: 'Zeigt den Prozentsatz des aktiven Drucks und einen Fortschrittsring im Browser-Tab an.',
// Archive
autoArchivePrints: 'Drucke automatisch archivieren',
autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
+2
View File
@@ -2455,6 +2455,8 @@ export default {
styleGlow: 'Glow',
styleVibrant: 'Vibrant',
themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
progressInTitle: 'Print progress in tab',
progressInTitleDescription: 'Show the active print\'s percentage and a progress ring in the browser tab.',
// Archive
autoArchivePrints: 'Auto-archive prints',
autoArchiveDescription: 'Automatically save 3MF files when prints complete',
+2
View File
@@ -2439,6 +2439,8 @@ export default {
styleGlow: 'Resplandor',
styleVibrant: 'Vibrante',
themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
progressInTitle: 'Progreso en la pestaña',
progressInTitleDescription: 'Muestra el porcentaje de la impresión activa y un anillo de progreso en la pestaña del navegador.',
// Archive
autoArchivePrints: 'Archivar impresiones automáticamente',
autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
+2
View File
@@ -2391,6 +2391,8 @@ export default {
styleGlow: 'Lumineux',
styleVibrant: 'Vif',
themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
progressInTitle: 'Progression dans l\'onglet',
progressInTitleDescription: 'Affiche le pourcentage de l\'impression en cours et un anneau de progression dans l\'onglet du navigateur.',
autoArchivePrints: 'Archiver automatiquement les impressions',
autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
+2
View File
@@ -2390,6 +2390,8 @@ export default {
styleGlow: 'Luminoso',
styleVibrant: 'Vibrante',
themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
progressInTitle: 'Avanzamento nella scheda',
progressInTitleDescription: 'Mostra la percentuale della stampa attiva e un anello di avanzamento nella scheda del browser.',
autoArchivePrints: 'Archiviazione automatica stampe',
autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
+2
View File
@@ -2435,6 +2435,8 @@ export default {
styleGlow: 'グロー',
styleVibrant: 'ビビッド',
themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
progressInTitle: 'タブに印刷の進捗を表示',
progressInTitleDescription: 'ブラウザのタブに進行中の印刷の進捗率と進捗リングを表示します。',
// Archive
autoArchivePrints: '印刷を自動アーカイブ',
autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
+2
View File
@@ -2306,6 +2306,8 @@ export default {
styleGlow: '글로우',
styleVibrant: '비브런트',
themeToggleHint: '사이드바의 태양/달 아이콘으로 다크 모드와 라이트 모드를 전환하세요.',
progressInTitle: '탭에 인쇄 진행률 표시',
progressInTitleDescription: '브라우저 탭에 진행 중인 인쇄의 백분율과 진행 링을 표시합니다.',
autoArchivePrints: '인쇄 자동 아카이브',
autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
+2
View File
@@ -2390,6 +2390,8 @@ export default {
styleGlow: 'Brilhante',
styleVibrant: 'Vibrante',
themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
progressInTitle: 'Progresso na aba',
progressInTitleDescription: 'Mostra a porcentagem da impressão ativa e um anel de progresso na aba do navegador.',
autoArchivePrints: 'Arquivar impressões automaticamente',
autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
+2
View File
@@ -2307,6 +2307,8 @@ export default {
styleGlow: "Свечение",
styleVibrant: "Насыщенный",
themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.",
progressInTitle: "Прогресс во вкладке",
progressInTitleDescription: "Показывает процент текущей печати и кольцо прогресса во вкладке браузера.",
autoArchivePrints: "Автоматически архивировать печать",
autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
+2
View File
@@ -2440,6 +2440,8 @@ export default {
styleGlow: 'Parıltı',
styleVibrant: 'Canlı',
themeToggleHint: 'Kenar çubuğundaki güneş/ay simgesini kullanarak koyu ve açık mod arasında geçiş yapın.',
progressInTitle: 'Sekmede baskı ilerlemesi',
progressInTitleDescription: 'Tarayıcı sekmesinde etkin baskının yüzdesini ve bir ilerleme halkası gösterir.',
// Arşiv
autoArchivePrints: 'Baskıları otomatik arşivle',
autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
+2
View File
@@ -2455,6 +2455,8 @@ export default {
styleGlow: "Світіння",
styleVibrant: "Яскравий",
themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.",
progressInTitle: "Прогрес у вкладці",
progressInTitleDescription: "Показує відсоток активного друку та кільце прогресу на вкладці браузера.",
// Archive
autoArchivePrints: "Автоматично архівувати друки",
autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
+2
View File
@@ -2435,6 +2435,8 @@ export default {
styleGlow: '发光',
styleVibrant: '鲜艳',
themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
progressInTitle: '在标签页显示打印进度',
progressInTitleDescription: '在浏览器标签页中显示当前打印的百分比和进度环。',
autoArchivePrints: '自动归档打印',
autoArchiveDescription: '打印完成时自动保存3MF文件',
saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
+2
View File
@@ -2435,6 +2435,8 @@ export default {
styleGlow: '發光',
styleVibrant: '鮮豔',
themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
progressInTitle: '在分頁顯示列印進度',
progressInTitleDescription: '在瀏覽器分頁中顯示目前列印的百分比和進度環。',
autoArchivePrints: '自動歸檔列印',
autoArchiveDescription: '列印完成時自動儲存3MF檔案',
saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
+19
View File
@@ -172,6 +172,7 @@ export function SettingsPage() {
setMode,
setDarkStyle, setDarkBackground, setDarkAccent,
setLightStyle, setLightBackground, setLightAccent,
progressInTitle, setProgressInTitle,
} = useTheme();
const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
// Transient typed strings for the per-filament humidity threshold inputs
@@ -1811,6 +1812,24 @@ export function SettingsPage() {
<p className="text-xs text-bambu-gray">
{t('settings.themeToggleHint')}
</p>
<div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('settings.progressInTitle')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.progressInTitleDescription')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={progressInTitle}
onChange={(e) => { setProgressInTitle(e.target.checked); showToast(t('settings.toast.settingsSaved'), 'success'); }}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</CardContent>
</Card>