| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155 |
- 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);
- }
- };
- }, []);
- }
|