StreamOverlayPage.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useParams, useSearchParams } from 'react-router-dom';
  3. import { useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { Layers, Clock, Timer, Printer } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { PrinterStatus } from '../api/client';
  7. type OverlaySize = 'small' | 'medium' | 'large';
  8. interface OverlayConfig {
  9. size: OverlaySize;
  10. fps: number;
  11. showCamera: boolean;
  12. showProgress: boolean;
  13. showLayers: boolean;
  14. showEta: boolean;
  15. showFilename: boolean;
  16. showStatus: boolean;
  17. showPrinter: boolean;
  18. }
  19. function parseConfig(params: URLSearchParams): OverlayConfig {
  20. const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
  21. // Parse FPS (default 15, max 30, min 1)
  22. const fpsParam = parseInt(params.get('fps') || '15', 10);
  23. const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
  24. // Parse camera toggle (default true, set camera=false to hide)
  25. const cameraParam = params.get('camera');
  26. const showCamera = cameraParam !== 'false' && cameraParam !== '0';
  27. return {
  28. size: (params.get('size') as OverlaySize) || 'medium',
  29. fps,
  30. showCamera,
  31. showProgress: show.includes('progress'),
  32. showLayers: show.includes('layers'),
  33. showEta: show.includes('eta'),
  34. showFilename: show.includes('filename'),
  35. showStatus: show.includes('status'),
  36. showPrinter: show.includes('printer'),
  37. };
  38. }
  39. function formatTime(seconds: number): string {
  40. const hours = Math.floor(seconds / 3600);
  41. const minutes = Math.floor((seconds % 3600) / 60);
  42. return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
  43. }
  44. function formatETA(remainingMinutes: number): string {
  45. const now = new Date();
  46. const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
  47. const today = new Date();
  48. today.setHours(0, 0, 0, 0);
  49. const etaDay = new Date(eta);
  50. etaDay.setHours(0, 0, 0, 0);
  51. const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  52. if (etaDay.getTime() === today.getTime()) {
  53. return timeStr;
  54. } else if (etaDay.getTime() === today.getTime() + 86400000) {
  55. return `Tomorrow ${timeStr}`;
  56. } else {
  57. return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
  58. }
  59. }
  60. function getStatusText(status: PrinterStatus): string {
  61. if (status.stg_cur_name) return status.stg_cur_name;
  62. switch (status.state) {
  63. case 'RUNNING': return 'Printing';
  64. case 'PAUSE': return 'Paused';
  65. case 'FINISH': return 'Finished';
  66. case 'FAILED': return 'Failed';
  67. case 'IDLE': return 'Idle';
  68. default: return status.state || 'Unknown';
  69. }
  70. }
  71. function getSizeClasses(size: OverlaySize) {
  72. switch (size) {
  73. case 'small':
  74. return {
  75. container: 'p-3',
  76. text: 'text-sm',
  77. textLarge: 'text-lg',
  78. progressHeight: 'h-2',
  79. icon: 'w-3 h-3',
  80. gap: 'gap-2',
  81. logoHeight: 'h-12',
  82. };
  83. case 'large':
  84. return {
  85. container: 'p-6',
  86. text: 'text-xl',
  87. textLarge: 'text-3xl',
  88. progressHeight: 'h-4',
  89. icon: 'w-6 h-6',
  90. gap: 'gap-4',
  91. logoHeight: 'h-24',
  92. };
  93. case 'medium':
  94. default:
  95. return {
  96. container: 'p-4',
  97. text: 'text-base',
  98. textLarge: 'text-xl',
  99. progressHeight: 'h-3',
  100. icon: 'w-4 h-4',
  101. gap: 'gap-3',
  102. logoHeight: 'h-16',
  103. };
  104. }
  105. }
  106. export function StreamOverlayPage() {
  107. const { printerId } = useParams<{ printerId: string }>();
  108. const [searchParams] = useSearchParams();
  109. const queryClient = useQueryClient();
  110. const id = parseInt(printerId || '0', 10);
  111. const [imageKey, setImageKey] = useState(Date.now());
  112. const config = useMemo(() => parseConfig(searchParams), [searchParams]);
  113. const sizes = getSizeClasses(config.size);
  114. // Fetch printer info
  115. const { data: printer } = useQuery({
  116. queryKey: ['printer', id],
  117. queryFn: () => api.getPrinter(id),
  118. enabled: id > 0,
  119. });
  120. // Fetch printer status with polling
  121. const { data: status } = useQuery({
  122. queryKey: ['printerStatus', id],
  123. queryFn: () => api.getPrinterStatus(id),
  124. enabled: id > 0,
  125. refetchInterval: 2000,
  126. });
  127. // WebSocket for real-time updates
  128. useEffect(() => {
  129. if (!id) return;
  130. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  131. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
  132. const ws = new WebSocket(wsUrl);
  133. ws.onmessage = (event) => {
  134. try {
  135. const data = JSON.parse(event.data);
  136. if (data.type === 'printer_status' && data.printer_id === id) {
  137. queryClient.setQueryData(['printerStatus', id], data.status);
  138. }
  139. } catch {
  140. // Ignore parse errors
  141. }
  142. };
  143. ws.onerror = () => {
  144. // WebSocket error - polling will continue as fallback
  145. };
  146. return () => {
  147. ws.close();
  148. };
  149. }, [id, queryClient]);
  150. // Update document title
  151. useEffect(() => {
  152. document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
  153. return () => {
  154. document.title = 'Bambuddy';
  155. };
  156. }, [printer]);
  157. // Refresh stream on error
  158. const handleStreamError = () => {
  159. setTimeout(() => {
  160. setImageKey(Date.now());
  161. }, 3000);
  162. };
  163. if (!id) {
  164. return (
  165. <div className="min-h-screen bg-black flex items-center justify-center">
  166. <p className="text-white">Invalid printer ID</p>
  167. </div>
  168. );
  169. }
  170. if (!status) {
  171. return (
  172. <div className="min-h-screen bg-black flex items-center justify-center">
  173. <p className="text-gray-400">Loading...</p>
  174. </div>
  175. );
  176. }
  177. const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
  178. const progress = status.progress || 0;
  179. const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
  180. return (
  181. <div className="min-h-screen bg-black relative overflow-hidden">
  182. {/* Camera feed - fullscreen background (optional) */}
  183. {config.showCamera && (
  184. <img
  185. key={imageKey}
  186. src={streamUrl}
  187. alt="Camera stream"
  188. className="absolute inset-0 w-full h-full object-contain"
  189. onError={handleStreamError}
  190. />
  191. )}
  192. {/* Bambuddy logo - top right */}
  193. <a
  194. href="https://github.com/maziggy/bambuddy"
  195. target="_blank"
  196. rel="noopener noreferrer"
  197. className="absolute top-4 right-4 z-10"
  198. >
  199. <img
  200. src="/img/bambuddy_logo_dark_transparent.png"
  201. alt="Bambuddy"
  202. className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
  203. />
  204. </a>
  205. {/* Status overlay - bottom */}
  206. <div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
  207. <div className={`${sizes.container}`}>
  208. {/* Printer name */}
  209. {config.showPrinter && printer && (
  210. <div className={`flex items-center ${sizes.gap} mb-2`}>
  211. <Printer className={`${sizes.icon} text-white/70`} />
  212. <span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
  213. </div>
  214. )}
  215. {/* Filename */}
  216. {config.showFilename && status.current_print && (
  217. <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
  218. {status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
  219. </div>
  220. )}
  221. {/* Status text */}
  222. {config.showStatus && (
  223. <div className={`${sizes.text} text-white/70 mb-2`}>
  224. {getStatusText(status)}
  225. </div>
  226. )}
  227. {/* Progress bar */}
  228. {config.showProgress && isPrinting && (
  229. <div className="mb-3">
  230. <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
  231. <span className="text-white/70">Progress</span>
  232. <span className="text-white font-bold">{Math.round(progress)}%</span>
  233. </div>
  234. <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
  235. <div
  236. className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
  237. style={{ width: `${progress}%` }}
  238. />
  239. </div>
  240. </div>
  241. )}
  242. {/* Stats row */}
  243. {isPrinting && (config.showLayers || config.showEta) && (
  244. <div className={`flex items-center ${sizes.gap} flex-wrap`}>
  245. {/* Layers */}
  246. {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
  247. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  248. <Layers className={sizes.icon} />
  249. <span className={sizes.text}>
  250. <span className="text-white">{status.layer_num}</span>
  251. <span className="mx-1">/</span>
  252. <span>{status.total_layers}</span>
  253. </span>
  254. </div>
  255. )}
  256. {/* Remaining time */}
  257. {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
  258. <>
  259. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  260. <Timer className={sizes.icon} />
  261. <span className={`${sizes.text} text-white`}>
  262. {formatTime(status.remaining_time * 60)}
  263. </span>
  264. </div>
  265. <div className={`flex items-center ${sizes.gap} text-white/70`}>
  266. <Clock className={sizes.icon} />
  267. <span className={`${sizes.text} text-white`}>
  268. ETA {formatETA(status.remaining_time)}
  269. </span>
  270. </div>
  271. </>
  272. )}
  273. </div>
  274. )}
  275. {/* Idle state */}
  276. {!isPrinting && (
  277. <div className={`${sizes.text} text-white/70 py-2`}>
  278. {status.connected ? 'Printer is idle' : 'Printer offline'}
  279. </div>
  280. )}
  281. </div>
  282. </div>
  283. </div>
  284. );
  285. }