StreamOverlayPage.tsx 11 KB

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