Browse Source

Remember the streaming overlay builder's choices in the browser

Every choice in Settings → Camera → Streaming Overlay was lost on reload
or when leaving the page. They are now kept in local storage, checked
like an imported URL when read back; the token is never stored. A Reset
choices link restores the defaults.

A "Settings saved" toast confirms each change once the edits settle.
maziggy 2 days ago
parent
commit
967fe80baa

+ 1 - 0
CHANGELOG.md

@@ -64,6 +64,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 
 ### Changed
 ### Changed
+- **The Streaming Overlay builder remembers your choices** — Settings → Camera → Streaming Overlay kept its choices only while the page was open, so the fields to show, printer, size, frame rate, layout, artwork, transparency, camera feed and progress colours were back at their defaults after a reload or a visit to another page. They are now remembered in the browser you used, with a "Settings saved" toast once your edits settle. The token is still never stored. A new **Reset choices** link puts everything back to the defaults and keeps the token and the uploaded logo.
 - **The camera settings stay short on large farms** — Settings → Camera listed every printer with its external camera switch and fields, so a farm of 120 printers got a card many screens long. The **External Cameras** list now holds only the printers that have one: **Add external camera** picks a printer from a list you can search by name or location, each row shows the camera type and address and folds its fields away, and a bin icon switches the printer back to its built-in camera.
 - **The camera settings stay short on large farms** — Settings → Camera listed every printer with its external camera switch and fields, so a farm of 120 printers got a card many screens long. The **External Cameras** list now holds only the printers that have one: **Add external camera** picks a printer from a list you can search by name or location, each row shows the camera type and address and folds its fields away, and a bin icon switches the printer back to its built-in camera.
 - **Camera settings have their own tab, and the settings menu is sorted alphabetically** — Camera settings were split across two tabs: external cameras under General, and camera tokens and the Streaming Overlay builder under API Keys. A new **Camera** tab now holds all three: **External Cameras**, **Camera API Tokens** and **Streaming Overlay**. Old links to the tokens or the overlay builder, including `/camera-tokens`, open the Camera tab. API Keys keeps API keys, webhooks, Connected Apps and the API browser, and is no longer shown to a user who may see none of them; before, such a user only went there for their camera tokens, so an API Keys link now opens the Camera tab for them. Settings search finds the Streaming Overlay builder. The settings menu now lists **General** first and the other tabs alphabetically in the interface language. In Connected Apps, the **Add app** button no longer runs past the edge of the card on mid-sized screens, and the Brazilian Portuguese camera button on the printer card reads "Câmera" again instead of "âmera". Wiki updated. Covered by frontend tests.
 - **Camera settings have their own tab, and the settings menu is sorted alphabetically** — Camera settings were split across two tabs: external cameras under General, and camera tokens and the Streaming Overlay builder under API Keys. A new **Camera** tab now holds all three: **External Cameras**, **Camera API Tokens** and **Streaming Overlay**. Old links to the tokens or the overlay builder, including `/camera-tokens`, open the Camera tab. API Keys keeps API keys, webhooks, Connected Apps and the API browser, and is no longer shown to a user who may see none of them; before, such a user only went there for their camera tokens, so an API Keys link now opens the Camera tab for them. Settings search finds the Streaming Overlay builder. The settings menu now lists **General** first and the other tabs alphabetically in the interface language. In Connected Apps, the **Add app** button no longer runs past the edge of the card on mid-sized screens, and the Brazilian Portuguese camera button on the printer card reads "Câmera" again instead of "âmera". Wiki updated. Covered by frontend tests.
 - **The Slice dialog uses more of a large screen** — The dialog stopped at 1024 px wide however large the screen, and its left column was fixed at 320 px. So preset names were cut off, and every filament row wrapped its label and its "hidden / Show all" note, while the process-settings panel beside it sat half empty. The dialog now widens to 1280 px on wide screens and 1536 px on very wide ones, and the left column takes two fifths of it instead of a fixed width. Below 1024 px it is still a single column.
 - **The Slice dialog uses more of a large screen** — The dialog stopped at 1024 px wide however large the screen, and its left column was fixed at 320 px. So preset names were cut off, and every filament row wrapped its label and its "hidden / Show all" note, while the process-settings panel beside it sat half empty. The dialog now widens to 1280 px on wide screens and 1536 px on very wide ones, and the left column takes two fifths of it instead of a fixed width. Below 1024 px it is still a single column.

+ 149 - 0
frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx

@@ -646,3 +646,152 @@ it.each([
   expect(new URL(shownUrl()).searchParams.has('logo')).toBe(false);
   expect(new URL(shownUrl()).searchParams.has('logo')).toBe(false);
   expect(screen.getByLabelText('Upload logo')).toBeEnabled();
   expect(screen.getByLabelText('Upload logo')).toBeEnabled();
 });
 });
+
+describe('StreamOverlayBuilder remembers its choices in this browser', () => {
+  const store = new Map<string, string>();
+  const KEY = 'bambuddy.streamOverlayBuilder';
+
+  beforeEach(() => {
+    store.clear();
+    vi.mocked(window.localStorage.getItem).mockImplementation((key: string) => store.get(key) ?? null);
+    vi.mocked(window.localStorage.setItem).mockImplementation((key: string, value: string) => {
+      store.set(key, value);
+    });
+    server.use(
+      http.get('/api/v1/printers', () => HttpResponse.json(printers)),
+      http.get('/api/v1/settings/overlay-logo', () => new HttpResponse(null, { status: 404 })),
+    );
+  });
+  afterEach(() => {
+    vi.mocked(window.localStorage.getItem).mockReset();
+    vi.mocked(window.localStorage.setItem).mockReset();
+  });
+
+  it('reopens with the fields, printer and look that were picked, and never stores the token', async () => {
+    const user = userEvent.setup();
+    const first = render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    await user.click(screen.getByLabelText('Nozzle'));
+    await user.click(screen.getByLabelText('Layer count'));
+    await user.selectOptions(screen.getByLabelText('Printer'), '2');
+    await user.selectOptions(screen.getByLabelText('Layout'), 'portrait');
+    fireEvent.change(screen.getByLabelText('From colour (hex)'), { target: { value: '#ff0000' } });
+    fireEvent.change(screen.getByLabelText('To colour (hex)'), { target: { value: '#0000ff' } });
+    await user.type(screen.getByLabelText('Manual token'), 'bblt_secret');
+    const withoutToken = (raw: string) => {
+      const url = new URL(raw);
+      url.searchParams.delete('token');
+      return url.toString();
+    };
+    const before = withoutToken(shownUrl());
+    first.unmount();
+
+    expect(store.get(KEY)).not.toContain('bblt_secret');
+    expect(store.get(KEY)).not.toContain('token');
+
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    expect(screen.getByLabelText('Nozzle')).toBeChecked();
+    expect(screen.getByLabelText('Layer count')).not.toBeChecked();
+    expect(screen.getByLabelText('Printer')).toHaveValue('2');
+    expect(screen.getByLabelText('Layout')).toHaveValue('portrait');
+    expect(screen.getByLabelText('Manual token')).toHaveValue('');
+    // Same URL as before, minus the token that was not kept.
+    expect(shownUrl()).toBe(before);
+  });
+
+  it('falls back to the defaults for anything stored that does not check out', async () => {
+    store.set(KEY, JSON.stringify({
+      printerId: 99, fields: ['nozzle', 'evil'], size: 'huge', fps: 500, artwork: '7',
+      backgroundTransparency: -4, showCamera: 'no', layout: 'sideways', logo: 'yes', from: 'red', to: '#0000ff',
+    }));
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+
+    // Printer 99 no longer exists, so the first printer is used.
+    await waitFor(() => expect(screen.getByLabelText('Printer')).toHaveValue('1'));
+    const url = new URL(shownUrl());
+    expect(url.pathname).toBe('/overlay/1');
+    expect(url.searchParams.get('show')).toBe('nozzle');
+    expect(url.searchParams.get('fps')).toBe('30');
+    expect(url.searchParams.has('size')).toBe(false);
+    expect(url.searchParams.has('artwork')).toBe(false);
+    expect(url.searchParams.has('layout')).toBe(false);
+    expect(url.searchParams.has('camera')).toBe(false);
+    expect(url.searchParams.has('logo')).toBe(false);
+    expect(url.searchParams.has('progressFrom')).toBe(false);
+  });
+
+  it('starts from the defaults when the stored value is not JSON', async () => {
+    store.set(KEY, '{not json');
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    expect(new URL(shownUrl()).searchParams.get('show')).toBe('filename,status,progress,layers,eta');
+  });
+
+  it('keeps working when the browser refuses to store anything', async () => {
+    vi.mocked(window.localStorage.setItem).mockImplementation((key: string) => {
+      if (key === KEY) throw new Error('QuotaExceededError');
+    });
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    await user.click(screen.getByLabelText('Nozzle'));
+    expect(new URL(shownUrl()).searchParams.get('show')).toContain('nozzle');
+  });
+
+  it('says when a change is saved, once per burst of edits', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    await user.click(screen.getByLabelText('Nozzle'));
+    await user.click(screen.getByLabelText('Layer count'));
+    await user.selectOptions(screen.getByLabelText('Layout'), 'portrait');
+
+    await waitFor(() => expect(screen.getByText('Settings saved')).toBeInTheDocument());
+    // Give any later toast of the same burst time to appear before counting.
+    await new Promise((resolve) => setTimeout(resolve, 700));
+    expect(screen.getAllByText('Settings saved')).toHaveLength(1);
+  });
+
+  it('does not claim a save when the page only opens, even when a deleted printer is replaced', async () => {
+    store.set(KEY, JSON.stringify({ printerId: 99, fields: ['nozzle'] }));
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    await waitFor(() => expect(screen.getByLabelText('Printer')).toHaveValue('1'));
+    expect(JSON.parse(store.get(KEY)!).printerId).toBe(1);
+
+    await new Promise((resolve) => setTimeout(resolve, 900));
+    expect(screen.queryByText('Settings saved')).toBeNull();
+  });
+
+  it('does not claim a save the browser refused', async () => {
+    vi.mocked(window.localStorage.setItem).mockImplementation((key: string) => {
+      if (key === KEY) throw new Error('QuotaExceededError');
+    });
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    await user.click(screen.getByLabelText('Nozzle'));
+
+    await new Promise((resolve) => setTimeout(resolve, 900));
+    expect(screen.queryByText('Settings saved')).toBeNull();
+  });
+
+  it('resets the choices to the defaults but keeps the token', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    const defaultUrl = shownUrl();
+    await user.click(screen.getByLabelText('Nozzle'));
+    await user.selectOptions(screen.getByLabelText('Printer'), '2');
+    await user.type(screen.getByLabelText('Manual token'), 'bblt_keep');
+    await user.click(screen.getByRole('button', { name: 'Reset choices' }));
+
+    const url = new URL(shownUrl());
+    expect(url.pathname).toBe('/overlay/1');
+    expect(url.searchParams.get('show')).toBe(new URL(defaultUrl).searchParams.get('show'));
+    expect(url.searchParams.get('token')).toBe('****');
+    expect(JSON.parse(store.get(KEY)!).fields).toEqual(['progress', 'layers', 'eta', 'filename', 'status']);
+  });
+});

+ 150 - 11
frontend/src/components/StreamOverlayBuilder.tsx

@@ -7,8 +7,10 @@
  * that surface. Appearance is configured in the URL. Tokens entered, imported, or created
  * that surface. Appearance is configured in the URL. Tokens entered, imported, or created
  * here remain in component memory; existing credentials cannot be recovered.
  * here remain in component memory; existing credentials cannot be recovered.
  * The URL is the configuration so an OBS browser source needs no saved server-side profile.
  * The URL is the configuration so an OBS browser source needs no saved server-side profile.
+ * The choices that build it are remembered in this browser so the builder
+ * reopens as it was left; the token never is.
  */
  */
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
 import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
 import { api, type Printer } from '../api/client';
 import { api, type Printer } from '../api/client';
@@ -44,6 +46,82 @@ const DEFAULT_FIELDS = ['progress', 'layers', 'eta', 'filename', 'status'];
 
 
 const DEFAULT_FPS = 15;
 const DEFAULT_FPS = 15;
 
 
+// Everything that shapes the URL except the token, which stays in memory only.
+const STORAGE_KEY = 'bambuddy.streamOverlayBuilder';
+
+interface SavedChoices {
+  printerId: number | null;
+  fields: string[];
+  size: OverlaySize;
+  fps: number;
+  artwork: '1' | '2';
+  backgroundTransparency: number;
+  showCamera: boolean;
+  layout: OverlayLayout | 'both';
+  logo: boolean;
+  from: string;
+  to: string;
+}
+
+const DEFAULT_CHOICES: SavedChoices = {
+  printerId: null,
+  fields: DEFAULT_FIELDS,
+  size: 'medium',
+  fps: DEFAULT_FPS,
+  artwork: '1',
+  backgroundTransparency: 0,
+  showCamera: true,
+  layout: 'landscape',
+  logo: false,
+  from: '',
+  to: '',
+};
+
+/** The stored choices, each checked the way an imported URL is; anything off falls back to its default. */
+function loadChoices(): SavedChoices {
+  let raw: unknown;
+  try {
+    raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null');
+  } catch {
+    return DEFAULT_CHOICES;
+  }
+  if (!raw || typeof raw !== 'object') return DEFAULT_CHOICES;
+  const v = raw as Record<string, unknown>;
+  const num = (value: unknown, min: number, max: number, fallback: number) =>
+    typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
+  const gradient = typeof v.from === 'string' && typeof v.to === 'string' && overlayGradient(v.from, v.to);
+  return {
+    printerId: typeof v.printerId === 'number' && Number.isSafeInteger(v.printerId) && v.printerId > 0 ? v.printerId : null,
+    fields: Array.isArray(v.fields)
+      ? v.fields.filter((field): field is string => FIELDS.some((f) => f.key === field))
+      : DEFAULT_FIELDS,
+    size: v.size === 'small' || v.size === 'large' ? v.size : 'medium',
+    fps: Math.round(num(v.fps, 1, 30, DEFAULT_FPS)),
+    artwork: v.artwork === '2' ? '2' : '1',
+    backgroundTransparency: num(v.backgroundTransparency, 0, 100, 0),
+    showCamera: v.showCamera !== false,
+    layout: v.layout === 'portrait' || v.layout === 'both' ? v.layout : 'landscape',
+    logo: v.logo === true,
+    from: gradient ? (v.from as string) : '',
+    to: gradient ? (v.to as string) : '',
+  };
+}
+
+// One toast once a burst of edits settles: typing a colour or dragging the
+// transparency slider changes the choices many times in a row.
+const SAVED_TOAST_DELAY_MS = 600;
+
+function saveChoices(choices: SavedChoices): boolean {
+  try {
+    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(choices));
+    return true;
+  } catch {
+    // Private windows and full or blocked storage: the builder still works,
+    // it just starts from the defaults next time.
+    return false;
+  }
+}
+
 export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () => void }) {
 export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () => void }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
@@ -56,18 +134,24 @@ export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () =
   const [importUrl, setImportUrl] = useState('');
   const [importUrl, setImportUrl] = useState('');
   const [importError, setImportError] = useState(false);
   const [importError, setImportError] = useState(false);
 
 
+  const [saved] = useState(loadChoices);
+  const lastSavedRef = useRef<string | null>(null);
+  // Set when the builder itself replaces a remembered printer that no longer
+  // exists: that save is not the user's, so it gets no toast.
+  const quietSaveRef = useRef(false);
+  const savedToastRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const [printers, setPrinters] = useState<Printer[]>([]);
   const [printers, setPrinters] = useState<Printer[]>([]);
-  const [printerId, setPrinterId] = useState<number | null>(null);
-  const [fields, setFields] = useState<string[]>(DEFAULT_FIELDS);
-  const [size, setSize] = useState<OverlaySize>('medium');
-  const [fps, setFps] = useState(DEFAULT_FPS);
+  const [printerId, setPrinterId] = useState<number | null>(saved.printerId);
+  const [fields, setFields] = useState<string[]>(saved.fields);
+  const [size, setSize] = useState<OverlaySize>(saved.size);
+  const [fps, setFps] = useState(saved.fps);
   // '1' is the original overlay; the renderer is picked by version, not by a
   // '1' is the original overlay; the renderer is picked by version, not by a
   // name like "updated" that stops being true once there's a newer one.
   // name like "updated" that stops being true once there's a newer one.
-  const [artwork, setArtwork] = useState<'1' | '2'>('1');
-  const [backgroundTransparency, setBackgroundTransparency] = useState(0);
-  const [showCamera, setShowCamera] = useState(true);
-  const [branding, setBranding] = useState(DEFAULT_BRANDING);
-  const [layout, setLayout] = useState<OverlayLayout | 'both'>('landscape');
+  const [artwork, setArtwork] = useState<'1' | '2'>(saved.artwork);
+  const [backgroundTransparency, setBackgroundTransparency] = useState(saved.backgroundTransparency);
+  const [showCamera, setShowCamera] = useState(saved.showCamera);
+  const [branding, setBranding] = useState({ ...DEFAULT_BRANDING, logo: saved.logo, from: saved.from, to: saved.to });
+  const [layout, setLayout] = useState<OverlayLayout | 'both'>(saved.layout);
   const [brandingImportRevision, setBrandingImportRevision] = useState(0);
   const [brandingImportRevision, setBrandingImportRevision] = useState(0);
   const [brandingBusy, setBrandingBusy] = useState(false);
   const [brandingBusy, setBrandingBusy] = useState(false);
   const [preview, setPreview] = useState(false);
   const [preview, setPreview] = useState(false);
@@ -79,7 +163,17 @@ export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () =
         const list = await api.getPrinters();
         const list = await api.getPrinters();
         if (cancelled) return;
         if (cancelled) return;
         setPrinters(list);
         setPrinters(list);
-        if (list.length > 0) setPrinterId((current) => current ?? list[0].id);
+        // A remembered printer that has since been deleted falls back to the
+        // first one. An imported URL may name a printer of another server, so
+        // only the remembered id is checked.
+        if (list.length > 0) {
+          setPrinterId((current) => {
+            const next = current === null || (current === saved.printerId && !list.some((p) => p.id === current))
+              ? list[0].id : current;
+            if (next !== current) quietSaveRef.current = true;
+            return next;
+          });
+        }
       } catch {
       } catch {
         // A failed printer list only costs the picker its options — the builder
         // A failed printer list only costs the picker its options — the builder
         // still works if the user types a printer number into the URL by hand,
         // still works if the user types a printer number into the URL by hand,
@@ -90,8 +184,46 @@ export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () =
     return () => {
     return () => {
       cancelled = true;
       cancelled = true;
     };
     };
+  }, [saved.printerId]);
+
+  useEffect(() => {
+    const choices: SavedChoices = {
+      printerId, fields, size, fps, artwork, backgroundTransparency, showCamera, layout,
+      logo: branding.logo, from: branding.from, to: branding.to,
+    };
+    const serialized = JSON.stringify(choices);
+    if (serialized === lastSavedRef.current) return;
+    // The first pass only writes back what was just loaded.
+    const loading = lastSavedRef.current === null || quietSaveRef.current;
+    lastSavedRef.current = serialized;
+    quietSaveRef.current = false;
+    if (!saveChoices(choices) || loading) return;
+    if (savedToastRef.current) clearTimeout(savedToastRef.current);
+    savedToastRef.current = setTimeout(() => {
+      savedToastRef.current = null;
+      showToast(t('settings.toast.settingsSaved'));
+    }, SAVED_TOAST_DELAY_MS);
+  }, [printerId, fields, size, fps, artwork, backgroundTransparency, showCamera, layout, branding.logo, branding.from, branding.to, showToast, t]);
+
+  useEffect(() => () => {
+    if (savedToastRef.current) clearTimeout(savedToastRef.current);
   }, []);
   }, []);
 
 
+  const resetChoices = () => {
+    setPrinterId(printers[0]?.id ?? null);
+    setFields(DEFAULT_CHOICES.fields);
+    setSize(DEFAULT_CHOICES.size);
+    setFps(DEFAULT_CHOICES.fps);
+    setArtwork(DEFAULT_CHOICES.artwork);
+    setBackgroundTransparency(DEFAULT_CHOICES.backgroundTransparency);
+    setShowCamera(DEFAULT_CHOICES.showCamera);
+    setLayout(DEFAULT_CHOICES.layout);
+    // The uploaded logo stays on the server; only whether this URL uses it resets.
+    setBranding((current) => ({ ...current, logo: false, from: '', to: '' }));
+    setBrandingImportRevision((revision) => revision + 1);
+    setPreview(false);
+  };
+
   const outputs = useMemo(() => {
   const outputs = useMemo(() => {
     const id = printerId ?? 1;
     const id = printerId ?? 1;
     const params = new URLSearchParams();
     const params = new URLSearchParams();
@@ -209,6 +341,13 @@ export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () =
           '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.',
           '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.',
         )}
         )}
       </p>
       </p>
+      <div className="-mt-2 mb-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-bambu-gray">
+        <span>{t('streamOverlay.builder.rememberedHint')}</span>
+        <button type="button" onClick={resetChoices} disabled={submittingToken || brandingBusy}
+          className="text-bambu-green hover:underline disabled:opacity-50">
+          {t('streamOverlay.builder.resetChoices')}
+        </button>
+      </div>
 
 
       <div className="mb-4 space-y-2">
       <div className="mb-4 space-y-2">
         <label htmlFor="overlay-builder-import" className="block text-sm font-medium text-white">{t('streamOverlay.builder.importUrl')}</label>
         <label htmlFor="overlay-builder-import" className="block text-sm font-medium text-white">{t('streamOverlay.builder.importUrl')}</label>

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -3714,6 +3714,8 @@ export default {
       importUrl: 'Vorhandene Overlay-URL',
       importUrl: 'Vorhandene Overlay-URL',
       importAction: 'URL importieren',
       importAction: 'URL importieren',
       importHint: 'Importiere eine URL lokal, um ihr Token und ihre Einstellungen auf diesem Server zu verwenden. Einstellungen, die Bambuddy nicht kennt, werden ignoriert.',
       importHint: 'Importiere eine URL lokal, um ihr Token und ihre Einstellungen auf diesem Server zu verwenden. Einstellungen, die Bambuddy nicht kennt, werden ignoriert.',
+      rememberedHint: 'Deine Auswahl wird in diesem Browser gespeichert, das Token nicht.',
+      resetChoices: 'Auswahl zurücksetzen',
       importError: 'Gib eine gültige HTTP(S)-Overlay-URL mit positiver Druckernummer, höchstens einem Token und ohne Benutzername oder Passwort ein.',
       importError: 'Gib eine gültige HTTP(S)-Overlay-URL mit positiver Druckernummer, höchstens einem Token und ohne Benutzername oder Passwort ein.',
       manualToken: 'Token manuell eingeben',
       manualToken: 'Token manuell eingeben',
       title: 'Stream-Overlay',
       title: 'Stream-Overlay',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -3746,6 +3746,8 @@ export default {
       importUrl: 'Existing overlay URL',
       importUrl: 'Existing overlay URL',
       importAction: 'Import URL',
       importAction: 'Import URL',
       importHint: 'Import a URL locally to reuse its token and settings on this server. Settings Bambuddy doesn\'t recognise are ignored.',
       importHint: 'Import a URL locally to reuse its token and settings on this server. Settings Bambuddy doesn\'t recognise are ignored.',
+      rememberedHint: 'Your choices are remembered in this browser. The token is not.',
+      resetChoices: 'Reset choices',
       importError: 'Enter a valid HTTP(S) overlay URL with a positive printer number, at most one token and no user name or password.',
       importError: 'Enter a valid HTTP(S) overlay URL with a positive printer number, at most one token and no user name or password.',
       manualToken: 'Manual token',
       manualToken: 'Manual token',
       title: 'Streaming Overlay',
       title: 'Streaming Overlay',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -3716,6 +3716,8 @@ export default {
       importUrl: 'URL de superposición existente',
       importUrl: 'URL de superposición existente',
       importAction: 'Importar URL',
       importAction: 'Importar URL',
       importHint: 'Importa una URL localmente para reutilizar su token y ajustes en este servidor. Los ajustes que Bambuddy no reconoce se ignoran.',
       importHint: 'Importa una URL localmente para reutilizar su token y ajustes en este servidor. Los ajustes que Bambuddy no reconoce se ignoran.',
+      rememberedHint: 'Tus opciones se guardan en este navegador. El token no.',
+      resetChoices: 'Restablecer opciones',
       importError: 'Introduce una URL de superposición HTTP(S) válida con un número de impresora positivo, como máximo un token y sin usuario ni contraseña.',
       importError: 'Introduce una URL de superposición HTTP(S) válida con un número de impresora positivo, como máximo un token y sin usuario ni contraseña.',
       manualToken: 'Token manual',
       manualToken: 'Token manual',
       title: 'Superposición de emisión',
       title: 'Superposición de emisión',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -3702,6 +3702,8 @@ export default {
       importUrl: 'URL de superposition existante',
       importUrl: 'URL de superposition existante',
       importAction: 'Importer l’URL',
       importAction: 'Importer l’URL',
       importHint: 'Importez une URL localement pour réutiliser son jeton et ses réglages sur ce serveur. Les réglages que Bambuddy ne reconnaît pas sont ignorés.',
       importHint: 'Importez une URL localement pour réutiliser son jeton et ses réglages sur ce serveur. Les réglages que Bambuddy ne reconnaît pas sont ignorés.',
+      rememberedHint: 'Vos choix sont mémorisés dans ce navigateur. Le jeton ne l\'est pas.',
+      resetChoices: 'Réinitialiser les choix',
       importError: 'Saisissez une URL de superposition HTTP(S) valide avec un numéro d’imprimante positif, au plus un jeton et sans nom d’utilisateur ni mot de passe.',
       importError: 'Saisissez une URL de superposition HTTP(S) valide avec un numéro d’imprimante positif, au plus un jeton et sans nom d’utilisateur ni mot de passe.',
       manualToken: 'Jeton manuel',
       manualToken: 'Jeton manuel',
       title: 'Incrustation de diffusion',
       title: 'Incrustation de diffusion',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -3701,6 +3701,8 @@ export default {
       importUrl: 'URL di sovrapposizione esistente',
       importUrl: 'URL di sovrapposizione esistente',
       importAction: 'Importa URL',
       importAction: 'Importa URL',
       importHint: 'Importa un URL localmente per riutilizzare il token e le impostazioni su questo server. Le impostazioni che Bambuddy non riconosce vengono ignorate.',
       importHint: 'Importa un URL localmente per riutilizzare il token e le impostazioni su questo server. Le impostazioni che Bambuddy non riconosce vengono ignorate.',
+      rememberedHint: 'Le tue scelte vengono ricordate in questo browser. Il token no.',
+      resetChoices: 'Ripristina le scelte',
       importError: 'Inserisci un URL overlay HTTP(S) valido con un numero di stampante positivo, al massimo un token e senza nome utente o password.',
       importError: 'Inserisci un URL overlay HTTP(S) valido con un numero di stampante positivo, al massimo un token e senza nome utente o password.',
       manualToken: 'Token manuale',
       manualToken: 'Token manuale',
       title: 'Overlay per streaming',
       title: 'Overlay per streaming',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -3714,6 +3714,8 @@ export default {
       importUrl: '既存のオーバーレイURL',
       importUrl: '既存のオーバーレイURL',
       importAction: 'URLをインポート',
       importAction: 'URLをインポート',
       importHint: 'URLをローカルでインポートし、トークンと設定をこのサーバーで再利用します。Bambuddyが認識しない設定は無視されます。',
       importHint: 'URLをローカルでインポートし、トークンと設定をこのサーバーで再利用します。Bambuddyが認識しない設定は無視されます。',
+      rememberedHint: '選択内容はこのブラウザーに保存されます。トークンは保存されません。',
+      resetChoices: '選択をリセット',
       importError: '正のプリンター番号を含み、トークンが1つまでで、ユーザー名やパスワードを含まない有効なHTTP(S)オーバーレイURLを入力してください。',
       importError: '正のプリンター番号を含み、トークンが1つまでで、ユーザー名やパスワードを含まない有効なHTTP(S)オーバーレイURLを入力してください。',
       manualToken: '手動入力トークン',
       manualToken: '手動入力トークン',
       title: 'ストリームオーバーレイ',
       title: 'ストリームオーバーレイ',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -3537,6 +3537,8 @@ export default {
       importUrl: '기존 오버레이 URL',
       importUrl: '기존 오버레이 URL',
       importAction: 'URL 가져오기',
       importAction: 'URL 가져오기',
       importHint: 'URL을 로컬에서 가져와 토큰과 설정을 이 서버에서 재사용합니다. Bambuddy가 인식하지 못하는 설정은 무시됩니다.',
       importHint: 'URL을 로컬에서 가져와 토큰과 설정을 이 서버에서 재사용합니다. Bambuddy가 인식하지 못하는 설정은 무시됩니다.',
+      rememberedHint: '선택 내용은 이 브라우저에 저장됩니다. 토큰은 저장되지 않습니다.',
+      resetChoices: '선택 초기화',
       importError: '양수 프린터 번호가 있고 토큰이 최대 하나이며 사용자 이름이나 비밀번호가 없는 유효한 HTTP(S) 오버레이 URL을 입력하세요.',
       importError: '양수 프린터 번호가 있고 토큰이 최대 하나이며 사용자 이름이나 비밀번호가 없는 유효한 HTTP(S) 오버레이 URL을 입력하세요.',
       manualToken: '토큰 직접 입력',
       manualToken: '토큰 직접 입력',
       title: '스트림 오버레이',
       title: '스트림 오버레이',

+ 2 - 0
frontend/src/i18n/locales/nl.ts

@@ -3744,6 +3744,8 @@ export default {
       importUrl: 'Bestaande overlay-URL',
       importUrl: 'Bestaande overlay-URL',
       importAction: 'URL importeren',
       importAction: 'URL importeren',
       importHint: 'Importeer een URL lokaal om het token en de instellingen op deze server te gebruiken. Instellingen die Bambuddy niet kent, worden genegeerd.',
       importHint: 'Importeer een URL lokaal om het token en de instellingen op deze server te gebruiken. Instellingen die Bambuddy niet kent, worden genegeerd.',
+      rememberedHint: 'Je keuzes worden in deze browser onthouden. Het token niet.',
+      resetChoices: 'Keuzes herstellen',
       importError: 'Voer een geldige HTTP(S)-overlay-URL in met een positief printernummer, hoogstens één token en zonder gebruikersnaam of wachtwoord.',
       importError: 'Voer een geldige HTTP(S)-overlay-URL in met een positief printernummer, hoogstens één token en zonder gebruikersnaam of wachtwoord.',
       manualToken: 'Handmatig token',
       manualToken: 'Handmatig token',
       title: 'Streamingoverlay',
       title: 'Streamingoverlay',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3701,6 +3701,8 @@ export default {
       importUrl: 'URL de sobreposição existente',
       importUrl: 'URL de sobreposição existente',
       importAction: 'Importar URL',
       importAction: 'Importar URL',
       importHint: 'Importe uma URL localmente para reutilizar o token e as configurações neste servidor. Configurações que o Bambuddy não reconhece são ignoradas.',
       importHint: 'Importe uma URL localmente para reutilizar o token e as configurações neste servidor. Configurações que o Bambuddy não reconhece são ignoradas.',
+      rememberedHint: 'Suas escolhas ficam salvas neste navegador. O token não.',
+      resetChoices: 'Redefinir escolhas',
       importError: 'Insira uma URL de sobreposição HTTP(S) válida com um número de impressora positivo, no máximo um token e sem usuário ou senha.',
       importError: 'Insira uma URL de sobreposição HTTP(S) válida com um número de impressora positivo, no máximo um token e sem usuário ou senha.',
       manualToken: 'Token manual',
       manualToken: 'Token manual',
       title: 'Sobreposição de transmissão',
       title: 'Sobreposição de transmissão',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -3529,6 +3529,8 @@ export default {
       importUrl: 'Существующий URL оверлея',
       importUrl: 'Существующий URL оверлея',
       importAction: 'Импортировать URL',
       importAction: 'Импортировать URL',
       importHint: 'Импортируйте URL локально, чтобы использовать его токен и настройки на этом сервере. Настройки, которые Bambuddy не распознаёт, игнорируются.',
       importHint: 'Импортируйте URL локально, чтобы использовать его токен и настройки на этом сервере. Настройки, которые Bambuddy не распознаёт, игнорируются.',
+      rememberedHint: 'Ваш выбор запоминается в этом браузере. Токен — нет.',
+      resetChoices: 'Сбросить выбор',
       importError: 'Введите корректный HTTP(S) URL оверлея с положительным номером принтера, не более чем одним токеном и без имени пользователя или пароля.',
       importError: 'Введите корректный HTTP(S) URL оверлея с положительным номером принтера, не более чем одним токеном и без имени пользователя или пароля.',
       manualToken: 'Токен вручную',
       manualToken: 'Токен вручную',
       title: "Оформление трансляции",
       title: "Оформление трансляции",

+ 2 - 0
frontend/src/i18n/locales/sv.ts

@@ -3743,6 +3743,8 @@ errors: {
       importUrl: 'Befintlig överläggs-URL',
       importUrl: 'Befintlig överläggs-URL',
       importAction: 'Importera URL',
       importAction: 'Importera URL',
       importHint: 'Importera en URL lokalt för att återanvända dess token och inställningar på den här servern. Inställningar som Bambuddy inte känner igen ignoreras.',
       importHint: 'Importera en URL lokalt för att återanvända dess token och inställningar på den här servern. Inställningar som Bambuddy inte känner igen ignoreras.',
+      rememberedHint: 'Dina val sparas i den här webbläsaren. Token gör det inte.',
+      resetChoices: 'Återställ val',
       importError: 'Ange en giltig HTTP(S)-overlay-URL med ett positivt skrivarnummer, högst en token och utan användarnamn eller lösenord.',
       importError: 'Ange en giltig HTTP(S)-overlay-URL med ett positivt skrivarnummer, högst en token och utan användarnamn eller lösenord.',
       manualToken: 'Manuell token',
       manualToken: 'Manuell token',
       title: 'Strömningsöverlägg',
       title: 'Strömningsöverlägg',

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -3716,6 +3716,8 @@ export default {
       importUrl: 'Mevcut katman URL’si',
       importUrl: 'Mevcut katman URL’si',
       importAction: 'URL’yi içe aktar',
       importAction: 'URL’yi içe aktar',
       importHint: 'Belirteci ve ayarları bu sunucuda yeniden kullanmak için URL’yi yerel olarak içe aktarın. Bambuddy’nin tanımadığı ayarlar yok sayılır.',
       importHint: 'Belirteci ve ayarları bu sunucuda yeniden kullanmak için URL’yi yerel olarak içe aktarın. Bambuddy’nin tanımadığı ayarlar yok sayılır.',
+      rememberedHint: 'Seçimleriniz bu tarayıcıda hatırlanır. Belirteç hatırlanmaz.',
+      resetChoices: 'Seçimleri sıfırla',
       importError: 'Pozitif bir yazıcı numarası, en fazla bir belirteç içeren ve kullanıcı adı ya da parola içermeyen geçerli bir HTTP(S) kaplama URL’si girin.',
       importError: 'Pozitif bir yazıcı numarası, en fazla bir belirteç içeren ve kullanıcı adı ya da parola içermeyen geçerli bir HTTP(S) kaplama URL’si girin.',
       manualToken: 'Manuel belirteç',
       manualToken: 'Manuel belirteç',
       title: 'Yayın Kaplaması',
       title: 'Yayın Kaplaması',

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -3741,6 +3741,8 @@ export default {
       importUrl: 'Наявний URL оверлею',
       importUrl: 'Наявний URL оверлею',
       importAction: 'Імпортувати URL',
       importAction: 'Імпортувати URL',
       importHint: 'Імпортуйте URL локально, щоб використати його токен і налаштування на цьому сервері. Налаштування, які Bambuddy не розпізнає, ігноруються.',
       importHint: 'Імпортуйте URL локально, щоб використати його токен і налаштування на цьому сервері. Налаштування, які Bambuddy не розпізнає, ігноруються.',
+      rememberedHint: 'Ваш вибір запам\'ятовується в цьому браузері. Токен — ні.',
+      resetChoices: 'Скинути вибір',
       importError: 'Введіть дійсний HTTP(S) URL оверлею з додатним номером принтера, щонайбільше одним токеном і без імені користувача чи пароля.',
       importError: 'Введіть дійсний HTTP(S) URL оверлею з додатним номером принтера, щонайбільше одним токеном і без імені користувача чи пароля.',
       manualToken: 'Токен вручну',
       manualToken: 'Токен вручну',
       title: "Оформлення трансляції",
       title: "Оформлення трансляції",

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3701,6 +3701,8 @@ export default {
       importUrl: '现有叠加层 URL',
       importUrl: '现有叠加层 URL',
       importAction: '导入 URL',
       importAction: '导入 URL',
       importHint: '在本地导入 URL,以在此服务器上重用其令牌和设置。Bambuddy 无法识别的设置将被忽略。',
       importHint: '在本地导入 URL,以在此服务器上重用其令牌和设置。Bambuddy 无法识别的设置将被忽略。',
+      rememberedHint: '你的选择会保存在此浏览器中,令牌不会。',
+      resetChoices: '重置选择',
       importError: '请输入有效的 HTTP(S) 叠加层 URL,需包含正整数打印机编号、最多一个令牌,且不含用户名或密码。',
       importError: '请输入有效的 HTTP(S) 叠加层 URL,需包含正整数打印机编号、最多一个令牌,且不含用户名或密码。',
       manualToken: '手动输入令牌',
       manualToken: '手动输入令牌',
       title: '直播叠加层',
       title: '直播叠加层',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3701,6 +3701,8 @@ export default {
       importUrl: '現有疊加層 URL',
       importUrl: '現有疊加層 URL',
       importAction: '匯入 URL',
       importAction: '匯入 URL',
       importHint: '在本機匯入 URL,以在此伺服器上重用其權杖和設定。Bambuddy 無法識別的設定將被忽略。',
       importHint: '在本機匯入 URL,以在此伺服器上重用其權杖和設定。Bambuddy 無法識別的設定將被忽略。',
+      rememberedHint: '你的選擇會儲存在此瀏覽器中,權杖不會。',
+      resetChoices: '重設選擇',
       importError: '請輸入有效的 HTTP(S) 疊加層 URL,需包含正整數印表機編號、最多一個權杖,且不含使用者名稱或密碼。',
       importError: '請輸入有效的 HTTP(S) 疊加層 URL,需包含正整數印表機編號、最多一個權杖,且不含使用者名稱或密碼。',
       manualToken: '手動輸入權杖',
       manualToken: '手動輸入權杖',
       title: '直播疊加層',
       title: '直播疊加層',

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BI-iV7YA.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-CrR9B4mg.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-czcNV5Hu.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-Cp5vuzkv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-wUaJu8TH.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-J6DEE_YS.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cp5vuzkv.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-D1FhwA-P.css">
     <link rel="stylesheet" crossorigin href="/assets/index-D1FhwA-P.css">
   </head>
   </head>

Some files were not shown because too many files changed in this diff