StreamOverlayBuilder.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. /**
  2. * Streaming-overlay URL builder (#1422).
  3. *
  4. * The overlay at /overlay/{printerId} has been configurable by query string
  5. * since #2613, but only for people who found the parameters in the wiki. The
  6. * issue asked for the field set to be selectable "through the web UI"; this is
  7. * that surface. Appearance is configured in the URL. Tokens entered, imported, or created
  8. * here remain in component memory; existing credentials cannot be recovered.
  9. * The URL is the configuration so an OBS browser source needs no saved server-side profile.
  10. * The choices that build it are remembered in this browser so the builder
  11. * reopens as it was left; the token never is.
  12. */
  13. import { useEffect, useMemo, useRef, useState } from 'react';
  14. import { useTranslation } from 'react-i18next';
  15. import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
  16. import { api, type Printer } from '../api/client';
  17. import { useToast } from '../contexts/ToastContext';
  18. import { OverlayBrandingControls } from './OverlayBrandingControls';
  19. import { DEFAULT_BRANDING, overlayGradient } from '../utils/overlayBranding';
  20. import { useAuth } from '../contexts/AuthContext';
  21. import { CreateTokenForm } from '../pages/CameraTokensPage';
  22. import { NumberInput } from './NumberInput';
  23. import { OverlayFrame } from './OverlayFrame';
  24. import { OVERLAY_DIMENSIONS, type OverlayLayout } from '../utils/overlayLayout';
  25. type OverlaySize = 'small' | 'medium' | 'large';
  26. // Order matters: it is the order the fields appear in the overlay, so the
  27. // checkbox list reads as a preview of the result.
  28. const FIELDS = [
  29. { key: 'printer', labelKey: 'streamOverlay.builder.fieldPrinter', fallback: 'Printer name' },
  30. { key: 'model', labelKey: 'streamOverlay.builder.fieldModel', fallback: 'Printer model' },
  31. { key: 'filename', labelKey: 'streamOverlay.builder.fieldFilename', fallback: 'File name' },
  32. { key: 'status', labelKey: 'streamOverlay.builder.fieldStatus', fallback: 'Status' },
  33. { key: 'progress', labelKey: 'streamOverlay.builder.fieldProgress', fallback: 'Progress bar' },
  34. { key: 'layers', labelKey: 'streamOverlay.builder.fieldLayers', fallback: 'Layer count' },
  35. { key: 'eta', labelKey: 'streamOverlay.builder.fieldEta', fallback: 'Time remaining and ETA' },
  36. { key: 'nozzle', labelKey: 'printers.heaterHistory.nozzle', fallback: 'Nozzle' },
  37. { key: 'bed', labelKey: 'printers.heaterHistory.bed', fallback: 'Bed' },
  38. { key: 'chamber', labelKey: 'printers.heaterHistory.chamber', fallback: 'Chamber' },
  39. ] as const;
  40. // Matches parseConfig() in StreamOverlayPage: the fields an overlay shows when
  41. // the URL carries no ?show= at all.
  42. const DEFAULT_FIELDS = ['progress', 'layers', 'eta', 'filename', 'status'];
  43. const DEFAULT_FPS = 15;
  44. // Everything that shapes the URL except the token, which stays in memory only.
  45. const STORAGE_KEY = 'bambuddy.streamOverlayBuilder';
  46. interface SavedChoices {
  47. printerId: number | null;
  48. fields: string[];
  49. size: OverlaySize;
  50. fps: number;
  51. artwork: '1' | '2';
  52. backgroundTransparency: number;
  53. showCamera: boolean;
  54. layout: OverlayLayout | 'both';
  55. logo: boolean;
  56. from: string;
  57. to: string;
  58. }
  59. const DEFAULT_CHOICES: SavedChoices = {
  60. printerId: null,
  61. fields: DEFAULT_FIELDS,
  62. size: 'medium',
  63. fps: DEFAULT_FPS,
  64. artwork: '1',
  65. backgroundTransparency: 0,
  66. showCamera: true,
  67. layout: 'landscape',
  68. logo: false,
  69. from: '',
  70. to: '',
  71. };
  72. /** The stored choices, each checked the way an imported URL is; anything off falls back to its default. */
  73. function loadChoices(): SavedChoices {
  74. let raw: unknown;
  75. try {
  76. raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null');
  77. } catch {
  78. return DEFAULT_CHOICES;
  79. }
  80. if (!raw || typeof raw !== 'object') return DEFAULT_CHOICES;
  81. const v = raw as Record<string, unknown>;
  82. const num = (value: unknown, min: number, max: number, fallback: number) =>
  83. typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
  84. const gradient = typeof v.from === 'string' && typeof v.to === 'string' && overlayGradient(v.from, v.to);
  85. return {
  86. printerId: typeof v.printerId === 'number' && Number.isSafeInteger(v.printerId) && v.printerId > 0 ? v.printerId : null,
  87. fields: Array.isArray(v.fields)
  88. ? v.fields.filter((field): field is string => FIELDS.some((f) => f.key === field))
  89. : DEFAULT_FIELDS,
  90. size: v.size === 'small' || v.size === 'large' ? v.size : 'medium',
  91. fps: Math.round(num(v.fps, 1, 30, DEFAULT_FPS)),
  92. artwork: v.artwork === '2' ? '2' : '1',
  93. backgroundTransparency: num(v.backgroundTransparency, 0, 100, 0),
  94. showCamera: v.showCamera !== false,
  95. layout: v.layout === 'portrait' || v.layout === 'both' ? v.layout : 'landscape',
  96. logo: v.logo === true,
  97. from: gradient ? (v.from as string) : '',
  98. to: gradient ? (v.to as string) : '',
  99. };
  100. }
  101. // One toast once a burst of edits settles: typing a colour or dragging the
  102. // transparency slider changes the choices many times in a row.
  103. const SAVED_TOAST_DELAY_MS = 600;
  104. function saveChoices(choices: SavedChoices): boolean {
  105. try {
  106. window.localStorage.setItem(STORAGE_KEY, JSON.stringify(choices));
  107. return true;
  108. } catch {
  109. // Private windows and full or blocked storage: the builder still works,
  110. // it just starts from the defaults next time.
  111. return false;
  112. }
  113. }
  114. export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () => void }) {
  115. const { t } = useTranslation();
  116. const { showToast } = useToast();
  117. const { user, hasPermission } = useAuth();
  118. const [creatingToken, setCreatingToken] = useState(false);
  119. const [submittingToken, setSubmittingToken] = useState(false);
  120. const [revealToken, setRevealToken] = useState(false);
  121. const [manualToken, setManualToken] = useState('');
  122. const token = manualToken;
  123. const [importUrl, setImportUrl] = useState('');
  124. const [importError, setImportError] = useState(false);
  125. const [saved] = useState(loadChoices);
  126. const lastSavedRef = useRef<string | null>(null);
  127. // Set when the builder itself replaces a remembered printer that no longer
  128. // exists: that save is not the user's, so it gets no toast.
  129. const quietSaveRef = useRef(false);
  130. const savedToastRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  131. const [printers, setPrinters] = useState<Printer[]>([]);
  132. const [printerId, setPrinterId] = useState<number | null>(saved.printerId);
  133. const [fields, setFields] = useState<string[]>(saved.fields);
  134. const [size, setSize] = useState<OverlaySize>(saved.size);
  135. const [fps, setFps] = useState(saved.fps);
  136. // '1' is the original overlay; the renderer is picked by version, not by a
  137. // name like "updated" that stops being true once there's a newer one.
  138. const [artwork, setArtwork] = useState<'1' | '2'>(saved.artwork);
  139. const [backgroundTransparency, setBackgroundTransparency] = useState(saved.backgroundTransparency);
  140. const [showCamera, setShowCamera] = useState(saved.showCamera);
  141. const [branding, setBranding] = useState({ ...DEFAULT_BRANDING, logo: saved.logo, from: saved.from, to: saved.to });
  142. const [layout, setLayout] = useState<OverlayLayout | 'both'>(saved.layout);
  143. const [brandingImportRevision, setBrandingImportRevision] = useState(0);
  144. const [brandingBusy, setBrandingBusy] = useState(false);
  145. const [preview, setPreview] = useState(false);
  146. useEffect(() => {
  147. let cancelled = false;
  148. void (async () => {
  149. try {
  150. const list = await api.getPrinters();
  151. if (cancelled) return;
  152. setPrinters(list);
  153. // A remembered printer that has since been deleted falls back to the
  154. // first one. An imported URL may name a printer of another server, so
  155. // only the remembered id is checked.
  156. if (list.length > 0) {
  157. setPrinterId((current) => {
  158. const next = current === null || (current === saved.printerId && !list.some((p) => p.id === current))
  159. ? list[0].id : current;
  160. if (next !== current) quietSaveRef.current = true;
  161. return next;
  162. });
  163. }
  164. } catch {
  165. // A failed printer list only costs the picker its options — the builder
  166. // still works if the user types a printer number into the URL by hand,
  167. // so this is not worth a toast on a settings page they may just be
  168. // scrolling past.
  169. }
  170. })();
  171. return () => {
  172. cancelled = true;
  173. };
  174. }, [saved.printerId]);
  175. useEffect(() => {
  176. const choices: SavedChoices = {
  177. printerId, fields, size, fps, artwork, backgroundTransparency, showCamera, layout,
  178. logo: branding.logo, from: branding.from, to: branding.to,
  179. };
  180. const serialized = JSON.stringify(choices);
  181. if (serialized === lastSavedRef.current) return;
  182. // The first pass only writes back what was just loaded.
  183. const loading = lastSavedRef.current === null || quietSaveRef.current;
  184. lastSavedRef.current = serialized;
  185. quietSaveRef.current = false;
  186. if (!saveChoices(choices) || loading) return;
  187. if (savedToastRef.current) clearTimeout(savedToastRef.current);
  188. savedToastRef.current = setTimeout(() => {
  189. savedToastRef.current = null;
  190. showToast(t('settings.toast.settingsSaved'));
  191. }, SAVED_TOAST_DELAY_MS);
  192. }, [printerId, fields, size, fps, artwork, backgroundTransparency, showCamera, layout, branding.logo, branding.from, branding.to, showToast, t]);
  193. useEffect(() => () => {
  194. if (savedToastRef.current) clearTimeout(savedToastRef.current);
  195. }, []);
  196. const resetChoices = () => {
  197. setPrinterId(printers[0]?.id ?? null);
  198. setFields(DEFAULT_CHOICES.fields);
  199. setSize(DEFAULT_CHOICES.size);
  200. setFps(DEFAULT_CHOICES.fps);
  201. setArtwork(DEFAULT_CHOICES.artwork);
  202. setBackgroundTransparency(DEFAULT_CHOICES.backgroundTransparency);
  203. setShowCamera(DEFAULT_CHOICES.showCamera);
  204. setLayout(DEFAULT_CHOICES.layout);
  205. // The uploaded logo stays on the server; only whether this URL uses it resets.
  206. setBranding((current) => ({ ...current, logo: false, from: '', to: '' }));
  207. setBrandingImportRevision((revision) => revision + 1);
  208. setPreview(false);
  209. };
  210. const outputs = useMemo(() => {
  211. const id = printerId ?? 1;
  212. const params = new URLSearchParams();
  213. // Emit ?show= in the canonical field order rather than click order, so the
  214. // same selection always produces the same URL.
  215. const selected = FIELDS.filter((f) => fields.includes(f.key)).map((f) => f.key);
  216. params.set('show', selected.join(','));
  217. if (size !== 'medium') params.set('size', size);
  218. if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
  219. if (artwork !== '1') params.set('artwork', artwork);
  220. if (artwork === '2' && backgroundTransparency > 0) {
  221. params.set('backgroundTransparency', String(backgroundTransparency));
  222. }
  223. if (!showCamera) params.set('camera', 'false');
  224. if (branding.logo) params.set('logo', '1');
  225. if (branding.from && branding.to) {
  226. params.set('progressFrom', branding.from);
  227. params.set('progressTo', branding.to);
  228. }
  229. if (token.trim()) params.set('token', token.trim());
  230. const layouts: OverlayLayout[] = layout === 'both' ? ['landscape', 'portrait'] : [layout];
  231. return layouts.map((orientation) => {
  232. if (orientation === 'portrait') params.set('layout', orientation);
  233. else params.delete('layout');
  234. return { layout: orientation, url: `${window.location.origin}/overlay/${id}?${params.toString()}` };
  235. });
  236. }, [printerId, fields, size, fps, showCamera, token, artwork, layout, branding, backgroundTransparency]);
  237. const displayedUrl = (url: string) => {
  238. const masked = new URL(url);
  239. if (masked.searchParams.has('token') && !revealToken) masked.searchParams.set('token', '****');
  240. return masked.toString();
  241. };
  242. const importExistingUrl = () => {
  243. try {
  244. const parsed = new URL(importUrl.trim());
  245. const match = /^\/overlay\/([1-9]\d*)\/?$/.exec(parsed.pathname);
  246. if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !match) throw new Error();
  247. const id = Number(match[1]);
  248. if (!Number.isSafeInteger(id)) throw new Error();
  249. const params = parsed.searchParams;
  250. if (params.getAll('token').length > 1) throw new Error();
  251. const importedFields = params.has('show') ? params.get('show')!.split(',').filter(
  252. (field) => FIELDS.some((supportedField) => supportedField.key === field),
  253. ) : DEFAULT_FIELDS;
  254. const sizeParam = params.get('size');
  255. const importedSize = sizeParam === 'small' || sizeParam === 'large' ? sizeParam : 'medium';
  256. const fpsParam = parseInt(params.get('fps') || '15', 10);
  257. const importedFps = Math.min(Math.max(Number.isNaN(fpsParam) ? DEFAULT_FPS : fpsParam, 1), 30);
  258. const transparencyParam = Number(params.get('backgroundTransparency'));
  259. const importedTransparency = Number.isFinite(transparencyParam)
  260. ? Math.min(100, Math.max(0, transparencyParam)) : 0;
  261. const from = params.get('progressFrom');
  262. const to = params.get('progressTo');
  263. const validGradient = overlayGradient(from, to);
  264. // Validate everything before updating any state. Never open the imported origin.
  265. setPrinterId(id);
  266. setLayout(params.get('layout') === 'portrait' ? 'portrait' : 'landscape');
  267. setFields(importedFields);
  268. setSize(importedSize as OverlaySize);
  269. setFps(importedFps);
  270. setArtwork(params.get('artwork') === '2' ? '2' : '1');
  271. setShowCamera(!['false', '0'].includes(params.get('camera') ?? ''));
  272. setBackgroundTransparency(importedTransparency);
  273. setBranding((current) => ({
  274. ...current, logo: params.get('logo') === '1',
  275. from: validGradient ? from! : '', to: validGradient ? to! : '',
  276. }));
  277. setBrandingImportRevision((revision) => revision + 1);
  278. setManualToken(params.get('token') ?? '');
  279. setPreview(false);
  280. setRevealToken(false);
  281. setImportUrl('');
  282. setImportError(false);
  283. } catch {
  284. setImportError(true);
  285. }
  286. };
  287. const toggleField = (key: string) => {
  288. setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
  289. };
  290. const copyUrl = async (url: string) => {
  291. try {
  292. // Same fallback as the token dialog: the clipboard API needs a secure
  293. // context, and plenty of Bambuddy installs are plain HTTP on a LAN.
  294. if (navigator.clipboard && window.isSecureContext) {
  295. await navigator.clipboard.writeText(url);
  296. } else {
  297. const ta = document.createElement('textarea');
  298. ta.value = url;
  299. ta.style.position = 'fixed';
  300. ta.style.opacity = '0';
  301. document.body.appendChild(ta);
  302. try {
  303. ta.select();
  304. if (!document.execCommand('copy')) throw new Error();
  305. } finally {
  306. document.body.removeChild(ta);
  307. }
  308. }
  309. showToast(t('cameraTokens.toast.copied', 'Copied to clipboard'));
  310. } catch {
  311. showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error');
  312. }
  313. };
  314. return (
  315. <div className="@container/overlay min-w-0">
  316. <p className="text-sm text-bambu-gray mb-4">
  317. {t(
  318. 'streamOverlay.builder.description',
  319. 'Build the URL for a streaming overlay — a full-screen camera view with live print data drawn over it, for OBS, a wall display, or any browser source. Pick the fields you want and copy the URL.',
  320. )}
  321. </p>
  322. <div className="-mt-2 mb-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-bambu-gray">
  323. <span>{t('streamOverlay.builder.rememberedHint')}</span>
  324. <button type="button" onClick={resetChoices} disabled={submittingToken || brandingBusy}
  325. className="text-bambu-green hover:underline disabled:opacity-50">
  326. {t('streamOverlay.builder.resetChoices')}
  327. </button>
  328. </div>
  329. <div className="mb-4 space-y-2">
  330. <label htmlFor="overlay-builder-import" className="block text-sm font-medium text-white">{t('streamOverlay.builder.importUrl')}</label>
  331. <input id="overlay-builder-import" type="password" autoComplete="new-password" spellCheck={false} value={importUrl} disabled={submittingToken || brandingBusy}
  332. onChange={(event) => { setImportUrl(event.target.value); setImportError(false); }}
  333. className="w-full min-w-0 px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary" />
  334. <button type="button" disabled={submittingToken || brandingBusy || !importUrl.trim()} onClick={importExistingUrl} className="px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md disabled:opacity-50">{t('streamOverlay.builder.importAction')}</button>
  335. <p className="text-xs text-bambu-gray">{t('streamOverlay.builder.importHint')}</p>
  336. {importError && <p role="alert" className="text-sm text-red-400">{t('streamOverlay.builder.importError')}</p>}
  337. </div>
  338. <div className="mb-4 space-y-2">
  339. {user && hasPermission('camera:view') && (
  340. <button type="button" disabled={submittingToken} onClick={() => setCreatingToken((current) => !current)} className="px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md">
  341. {t(creatingToken ? 'common.cancel' : 'streamOverlay.builder.createToken')}
  342. </button>
  343. )}
  344. {creatingToken && user && hasPermission('camera:view') && <CreateTokenForm fixedScope="overlay" onSubmittingChange={setSubmittingToken} onCreated={(created) => {
  345. onTokenCreated?.();
  346. if (!created.token) return;
  347. setPreview(false);
  348. setManualToken(created.token);
  349. setRevealToken(false);
  350. setCreatingToken(false);
  351. }} />}
  352. </div>
  353. <div className="grid grid-cols-1 gap-4 @min-[28rem]/overlay:grid-cols-2">
  354. <div>
  355. <label
  356. htmlFor="overlay-builder-printer"
  357. className="block text-sm font-medium text-white mb-1"
  358. >
  359. {t('streamOverlay.builder.printer', 'Printer')}
  360. </label>
  361. <select
  362. id="overlay-builder-printer"
  363. value={printerId ?? ''}
  364. onChange={(e) => setPrinterId(Number(e.target.value))}
  365. className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  366. >
  367. {printers.length === 0 && <option value="">{t('common.loading', 'Loading…')}</option>}
  368. {printerId !== null && !printers.some((printer) => printer.id === printerId) && <option value={printerId}>{printerId}</option>}
  369. {printers.map((p) => (
  370. <option key={p.id} value={p.id}>
  371. {p.name}
  372. </option>
  373. ))}
  374. </select>
  375. </div>
  376. <div>
  377. <label htmlFor="overlay-builder-size" className="block text-sm font-medium text-white mb-1">
  378. {t('streamOverlay.builder.size', 'Text size')}
  379. </label>
  380. <select
  381. id="overlay-builder-size"
  382. value={size}
  383. onChange={(e) => setSize(e.target.value as OverlaySize)}
  384. className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  385. >
  386. <option value="small">{t('streamOverlay.builder.sizeSmall', 'Small')}</option>
  387. <option value="medium">{t('streamOverlay.builder.sizeMedium', 'Medium')}</option>
  388. <option value="large">{t('streamOverlay.builder.sizeLarge', 'Large')}</option>
  389. </select>
  390. </div>
  391. <div>
  392. <label htmlFor="overlay-builder-layout" className="block text-sm font-medium text-white mb-1">
  393. {t('streamOverlay.builder.layout')}
  394. </label>
  395. <select id="overlay-builder-layout" value={layout}
  396. onChange={(e) => {
  397. const value = e.target.value;
  398. if (value === 'landscape' || value === 'portrait' || value === 'both') setLayout(value);
  399. }}
  400. className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none">
  401. <option value="landscape">{t('streamOverlay.builder.landscape')}</option>
  402. <option value="portrait">{t('streamOverlay.builder.portrait')}</option>
  403. <option value="both">{t('streamOverlay.builder.both')}</option>
  404. </select>
  405. </div>
  406. <div>
  407. <label htmlFor="overlay-builder-artwork" className="block text-sm font-medium text-white mb-1">
  408. {t('streamOverlay.builder.artwork', 'Artwork')}
  409. </label>
  410. <select
  411. id="overlay-builder-artwork"
  412. value={artwork}
  413. onChange={(e) => setArtwork(e.target.value as '1' | '2')}
  414. className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  415. >
  416. <option value="1">{t('streamOverlay.builder.artworkClassic', 'Classic')}</option>
  417. <option value="2">{t('streamOverlay.builder.artworkV2', 'Version 2')}</option>
  418. </select>
  419. </div>
  420. {artwork === '2' && (
  421. <div>
  422. <label htmlFor="overlay-builder-background-transparency" className="flex justify-between gap-2 text-sm font-medium text-white mb-1">
  423. <span>{t('streamOverlay.builder.backgroundTransparency')}</span>
  424. <span aria-hidden="true">{backgroundTransparency}%</span>
  425. </label>
  426. <input
  427. id="overlay-builder-background-transparency"
  428. type="range"
  429. min={0}
  430. max={100}
  431. step={1}
  432. value={backgroundTransparency}
  433. aria-valuetext={`${backgroundTransparency}%`}
  434. onChange={(event) => setBackgroundTransparency(Number(event.target.value))}
  435. className="w-full accent-bambu-green"
  436. />
  437. <p className="text-xs text-bambu-gray mt-1">{t('streamOverlay.builder.backgroundTransparencyHint')}</p>
  438. </div>
  439. )}
  440. <div>
  441. <label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
  442. {t('streamOverlay.builder.fps', 'Frame rate')}
  443. </label>
  444. <NumberInput
  445. id="overlay-builder-fps"
  446. min={1}
  447. max={30}
  448. value={fps}
  449. onChange={setFps}
  450. fallback={1}
  451. className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
  452. />
  453. <p className="text-xs text-bambu-gray mt-1">
  454. {t(
  455. 'streamOverlay.builder.fpsHint',
  456. 'A1 and P1 cameras top out around 5 fps whatever you ask for.',
  457. )}
  458. </p>
  459. </div>
  460. <div>
  461. <label htmlFor="overlay-builder-manual-token" className="block text-sm font-medium text-white mt-3 mb-1">
  462. {t('streamOverlay.builder.manualToken')}
  463. </label>
  464. <input
  465. id="overlay-builder-manual-token"
  466. type={revealToken ? 'text' : 'password'}
  467. value={manualToken}
  468. disabled={submittingToken}
  469. autoComplete="new-password"
  470. spellCheck={false}
  471. onChange={(event) => {
  472. setPreview(false);
  473. setRevealToken(false);
  474. setManualToken(event.target.value);
  475. }}
  476. placeholder="bblt_…"
  477. className="w-full min-w-0 px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary"
  478. />
  479. <button type="button" disabled={!token} onClick={() => setRevealToken((current) => !current)} className="mt-2 text-sm text-bambu-gray disabled:opacity-50">
  480. {t(revealToken ? 'streamOverlay.builder.hideToken' : 'streamOverlay.builder.showToken')}
  481. </button>
  482. <p className="text-xs text-bambu-gray mt-1">
  483. {t(
  484. 'streamOverlay.builder.credentialHint',
  485. 'Create a token above, enter an existing token, or import an overlay URL. Copy the URL before leaving: tokens are held only in memory and cannot be recovered from their stored hash.',
  486. )}
  487. </p>
  488. </div>
  489. </div>
  490. <fieldset className="mt-4">
  491. <legend className="text-sm font-medium text-white mb-2">
  492. {t('streamOverlay.builder.fields', 'Fields to show')}
  493. </legend>
  494. <div className="grid grid-cols-1 gap-2 @min-[24rem]/overlay:grid-cols-2 @min-[36rem]/overlay:grid-cols-3">
  495. {FIELDS.map((field) => (
  496. <label key={field.key} className="flex items-center gap-2 text-sm text-bambu-gray">
  497. <input
  498. type="checkbox"
  499. checked={fields.includes(field.key)}
  500. onChange={() => toggleField(field.key)}
  501. className="accent-bambu-green"
  502. />
  503. {t(field.labelKey, field.fallback)}
  504. </label>
  505. ))}
  506. <label className="flex items-center gap-2 text-sm text-bambu-gray">
  507. <input
  508. type="checkbox"
  509. checked={showCamera}
  510. onChange={(e) => setShowCamera(e.target.checked)}
  511. className="accent-bambu-green"
  512. />
  513. {t('streamOverlay.builder.fieldCamera', 'Camera feed')}
  514. </label>
  515. </div>
  516. <p className="text-xs text-bambu-gray mt-2">
  517. {t(
  518. 'streamOverlay.builder.chamberHint',
  519. 'Chamber temperature only appears on models with a real chamber sensor — P1 and A1 printers report a meaningless value, so it is left out there.',
  520. )}
  521. </p>
  522. </fieldset>
  523. <OverlayBrandingControls key={brandingImportRevision} value={branding} onChange={setBranding} onBusyChange={setBrandingBusy} />
  524. <div className="mt-4">
  525. {outputs.map(({ layout: orientation, url }) => (
  526. <fieldset key={orientation} className="min-w-0 mb-3">
  527. <legend className="text-sm font-medium text-white mb-1">
  528. {t('streamOverlay.builder.orientationUrl', { orientation: t(`streamOverlay.builder.${orientation}`) })}
  529. </legend>
  530. <p className="text-xs text-bambu-gray mb-2">
  531. {t('streamOverlay.builder.sourceDimensions', OVERLAY_DIMENSIONS[orientation])}
  532. </p>
  533. <div className="flex flex-wrap items-center gap-2">
  534. <code className="w-full px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono">
  535. {displayedUrl(url)}
  536. </code>
  537. <button type="button" onClick={() => void copyUrl(url)}
  538. className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90">
  539. <Copy className="w-4 h-4" />
  540. {t('cameraTokens.created.copy', 'Copy')}
  541. </button>
  542. <a href={url} target="_blank" rel="noopener noreferrer"
  543. className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80">
  544. <ExternalLink className="w-4 h-4" />
  545. {t('streamOverlay.builder.open', 'Open')}
  546. </a>
  547. </div>
  548. </fieldset>
  549. ))}
  550. {token.trim() && (
  551. <p className="text-xs text-bambu-gray mt-2">
  552. {t(
  553. 'streamOverlay.builder.tokenWarning',
  554. 'This URL contains a token — anyone who can read it can watch the stream and see the file name. Revoke the token to cut it off.',
  555. )}
  556. </p>
  557. )}
  558. </div>
  559. {/* The preview opens a real camera stream, so it stays off until asked
  560. for. Leaving one running behind a settings tab would hold a subscriber
  561. on the printer's single camera connection for as long as the tab is
  562. open. */}
  563. <div className="mt-4">
  564. <button
  565. type="button"
  566. onClick={() => setPreview((p) => !p)}
  567. className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 text-sm"
  568. >
  569. {preview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
  570. {preview
  571. ? t('streamOverlay.builder.hidePreview', 'Hide preview')
  572. : t('streamOverlay.builder.showPreview', 'Show preview')}
  573. </button>
  574. {preview && (
  575. <div className="mt-3 flex flex-wrap items-start gap-4">
  576. {outputs.map(({ layout: orientation, url }) => (
  577. <div key={orientation} className="min-w-0 flex-1 basis-64"
  578. style={orientation === 'portrait' ? { maxWidth: 360 } : undefined}>
  579. <p className="text-sm text-white mb-2">{t(`streamOverlay.builder.${orientation}`)}</p>
  580. <div className="overflow-hidden rounded-md border border-bambu-dark-tertiary">
  581. <OverlayFrame layout={orientation} preview>
  582. <OverlayPreview url={url} logoRevision={branding.logoRevision}
  583. title={t('streamOverlay.builder.orientationPreview', { orientation: t(`streamOverlay.builder.${orientation}`) })} />
  584. </OverlayFrame>
  585. </div>
  586. </div>
  587. ))}
  588. </div>
  589. )}
  590. </div>
  591. </div>
  592. );
  593. }
  594. function OverlayPreview({ url, logoRevision, title }: { url: string; logoRevision: number; title: string }) {
  595. const [source, setSource] = useState({ url, logoRevision });
  596. useEffect(() => {
  597. // Colour and transparency controls emit continuously while dragging.
  598. // Wait for them to settle before opening another camera stream.
  599. const timeout = window.setTimeout(() => setSource({ url, logoRevision }), 300);
  600. return () => window.clearTimeout(timeout);
  601. }, [url, logoRevision]);
  602. return <iframe
  603. key={`${source.url}:${source.logoRevision}`}
  604. src={source.url}
  605. title={title}
  606. className="w-full h-full border-0"
  607. />;
  608. }