usePrintProgressTitle.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { useQueries, useQuery } from '@tanstack/react-query';
  2. import { useEffect, useRef } from 'react';
  3. import { api } from '../api/client';
  4. import { useTheme } from '../contexts/ThemeContext';
  5. const DEFAULT_TITLE = 'Bambuddy';
  6. const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom)
  7. // A remaining_time <= 0 means "ETA not known yet" (the backend defaults it to 0,
  8. // not null), so treat it as unknown rather than "finishes now".
  9. const eta = (t: number | null): number => (t != null && t > 0 ? t : Infinity);
  10. // Only the fields we need — keeps pickActivePrint decoupled from the full
  11. // PrinterStatus type so the test can pass plain objects.
  12. export interface ProgressStatus {
  13. state: string | null;
  14. progress: number | null;
  15. remaining_time: number | null;
  16. }
  17. /**
  18. * Of all connected printers, pick the RUNNING print to surface in the tab:
  19. * the one finishing soonest (smallest remaining_time), tie-broken by highest
  20. * progress. Returns null when nothing is actively printing.
  21. */
  22. export function pickActivePrint<T extends ProgressStatus>(statuses: (T | undefined)[]): T | null {
  23. let best: T | null = null;
  24. for (const s of statuses) {
  25. if (!s || s.state !== 'RUNNING' || s.progress == null) continue;
  26. if (best === null) {
  27. best = s;
  28. continue;
  29. }
  30. const sr = eta(s.remaining_time);
  31. const br = eta(best.remaining_time);
  32. if (sr < br || (sr === br && (s.progress ?? 0) > (best.progress ?? 0))) {
  33. best = s;
  34. }
  35. }
  36. return best;
  37. }
  38. // Draw a 32x32 progress ring in the current theme accent colour, return a PNG
  39. // data URL (or null if the browser has no 2d canvas, e.g. under jsdom — the
  40. // caller then falls back to updating the title only).
  41. function drawProgressFavicon(pct: number): string | null {
  42. const canvas = document.createElement('canvas');
  43. canvas.width = 32;
  44. canvas.height = 32;
  45. const ctx = canvas.getContext('2d');
  46. if (!ctx) return null;
  47. const accent =
  48. getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
  49. FALLBACK_ACCENT;
  50. const cx = 16;
  51. const cy = 16;
  52. const r = 13;
  53. const frac = Math.max(0, Math.min(100, pct)) / 100;
  54. const start = -Math.PI / 2; // 12 o'clock
  55. ctx.lineWidth = 4;
  56. // Track
  57. ctx.beginPath();
  58. ctx.arc(cx, cy, r, 0, Math.PI * 2);
  59. ctx.strokeStyle = 'rgba(128,128,128,0.3)';
  60. ctx.stroke();
  61. // Progress arc, clockwise from the top
  62. ctx.beginPath();
  63. ctx.arc(cx, cy, r, start, start + frac * Math.PI * 2);
  64. ctx.strokeStyle = accent;
  65. ctx.lineCap = 'round';
  66. ctx.stroke();
  67. return canvas.toDataURL('image/png');
  68. }
  69. // Point the <link rel="icon"> tags at the ring (remembering originals), or
  70. // restore them when dataUrl is null. apple-touch-icon is a different rel token
  71. // so the `rel~="icon"` selector leaves it alone.
  72. function setFavicon(dataUrl: string | null, originals: Map<HTMLLinkElement, string>) {
  73. const links = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
  74. links.forEach((link) => {
  75. if (dataUrl) {
  76. if (!originals.has(link)) originals.set(link, link.href);
  77. link.href = dataUrl;
  78. } else {
  79. const orig = originals.get(link);
  80. if (orig !== undefined) link.href = orig;
  81. }
  82. });
  83. if (!dataUrl) originals.clear();
  84. }
  85. /**
  86. * When the "progress in tab" preference is on, reflect the soonest-finishing
  87. * print's percentage in document.title and draw a progress ring favicon in the
  88. * theme accent colour. Stays fully inert until enabled, and hands the tab back
  89. * to its defaults once disabled, idle, or unmounted.
  90. * Mounted once, globally, inside WebSocketProvider.
  91. */
  92. export function usePrintProgressTitle() {
  93. const { progressInTitle, resolvedMode, darkAccent, lightAccent } = useTheme();
  94. // Re-draw the ring when the active accent changes.
  95. const accent = resolvedMode === 'dark' ? darkAccent : lightAccent;
  96. const { data: printers } = useQuery({
  97. queryKey: ['printers'],
  98. queryFn: api.getPrinters,
  99. enabled: progressInTitle,
  100. });
  101. const statusQueries = useQueries({
  102. queries: (progressInTitle ? printers ?? [] : []).map((p) => ({
  103. queryKey: ['printerStatus', p.id],
  104. queryFn: () => api.getPrinterStatus(p.id),
  105. refetchInterval: 30000, // fallback; WebSocket drives live updates
  106. })),
  107. });
  108. const originalsRef = useRef<Map<HTMLLinkElement, string>>(new Map());
  109. // Whether we currently own the tab title/favicon. Lets us stay inert while
  110. // off (never touch the tab) yet still restore once if we ever took it over.
  111. const ownsRef = useRef(false);
  112. const active = progressInTitle ? pickActivePrint(statusQueries.map((q) => q.data)) : null;
  113. const pct = active && active.progress != null ? Math.round(active.progress) : null;
  114. useEffect(() => {
  115. if (progressInTitle && pct != null) {
  116. document.title = `${pct}% · ${DEFAULT_TITLE}`;
  117. setFavicon(drawProgressFavicon(pct), originalsRef.current);
  118. ownsRef.current = true;
  119. } else if (ownsRef.current) {
  120. // Disabled or idle after having taken over — hand the tab back.
  121. document.title = DEFAULT_TITLE;
  122. setFavicon(null, originalsRef.current);
  123. ownsRef.current = false;
  124. }
  125. // else: never owned the tab → leave it entirely alone.
  126. }, [progressInTitle, pct, accent]);
  127. // Restore the tab to defaults on unmount, but only if we own it.
  128. useEffect(() => {
  129. const originals = originalsRef.current;
  130. const owns = ownsRef;
  131. return () => {
  132. if (owns.current) {
  133. document.title = DEFAULT_TITLE;
  134. setFavicon(null, originals);
  135. }
  136. };
  137. }, []);
  138. }