Selaa lähdekoodia

Add opt-in portrait and landscape stream overlay artwork (#3183)

Adam Spice 2 päivää sitten
vanhempi
sitoutus
8781edffb7

BIN
frontend/public/img/bambuddy_logo_powered_by.png


+ 3 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -176,6 +176,7 @@ const DE_COGNATES = [
   'Diagnose',  // DE: same spelling/meaning as EN — camera diagnostic button label
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
   'Score',  // #1546 AI detection modal — established DE loanword (Duden)
+  'Version 2',  // stream overlay artwork picker (#3177) — same word in German
 ];
 
 // French cognates — many UI labels overlap with English exactly.
@@ -225,6 +226,7 @@ const FR_COGNATES = [
   'Simple', 'Expert',  // slicer settings visibility tiers — identical words in French
   'Support',  // same word in French
   'Photos', '{{count}} photo', '{{count}} photos',  // file details photo strip (#3077) — same word in French
+  'Version 2',  // stream overlay artwork picker (#3177) — same word in French
 ];
 
 // Italian cognates.
@@ -393,6 +395,7 @@ const SV_COGNATES = [
   'Port', '(System)', 'Autologin', 'Process', 'Filament {{n}}', 'Region', 'Global', 'Normal', 'Version', 'max {{n}}', 'Expert', 'Filament {{index}} ({{type}})', 'Material:',
   '(Inv)', 'Original', 'Commit', 'Extruder', 'Gradient', 'Proxy', 'Metadata', '{{count}} filament', 'Temp', 'Min', 'ntfy, Pushover, Discord, etc.', 'Hex: #{{hex}}', 'Designer',
   'Prefix', 'Trend', 'max(global {{global}}d, SKU {{sku}}d)', 'Slicer',
+  'Version 2',  // stream overlay artwork picker (#3177) — same word in Swedish
 ];
 
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim

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

@@ -68,6 +68,22 @@ describe('StreamOverlayBuilder', () => {
     expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', originalUrl);
   });
 
+  it('opts into artwork in the URL and preview and restores the original URL', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    const artwork = await screen.findByLabelText('Artwork');
+    expect(artwork).toHaveValue('1');
+    const original = shownUrl();
+    expect(new URL(original).searchParams.has('artwork')).toBe(false);
+    await user.selectOptions(artwork, 'Version 2');
+    expect(new URL(shownUrl()).searchParams.get('artwork')).toBe('2');
+    await user.click(screen.getByRole('button', { name: 'Show preview' }));
+    expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
+    await user.selectOptions(artwork, 'Classic');
+    expect(shownUrl()).toBe(original);
+    expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', original);
+  });
+
   it('switches printer', async () => {
     const user = userEvent.setup();
     render(<StreamOverlayBuilder />);

+ 89 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -106,6 +106,95 @@ describe('StreamOverlayPage', () => {
     vi.unstubAllGlobals();
   });
 
+  describe('updated artwork', () => {
+    it('maps legacy model codes in version 2', async () => {
+      server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model: 'BL-P001' })));
+      renderOverlayPage(1, '?artwork=2&show=model');
+      expect(await screen.findByText('X1C')).toBeInTheDocument();
+      expect(screen.queryByText('BL-P001')).not.toBeInTheDocument();
+    });
+
+    it.each(['', '?artwork=other', '?artwork=updated'])('retains the original renderer for %s', async (query) => {
+      const { container } = renderOverlayPage(1, query);
+      await screen.findByAltText('Bambuddy');
+      expect(container.querySelector('.updated-overlay')).not.toBeInTheDocument();
+      expect(screen.getByAltText('Camera stream')).toHaveClass('object-contain');
+    });
+    it('uses token data, rotation and both nozzles without inventing missing temperatures', async () => {
+      server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
+        ...mockStatusPrinting, name: 'Token printer', model: 'O1D', camera_rotation: 90,
+        time_format: '24h', temperatures: { nozzle: 210, nozzle_target: 220, nozzle_2: 215, bed: 45 },
+      })));
+      renderOverlayPage(1, '?artwork=2&token=bblt_test&show=model,nozzle,bed,chamber&size=large');
+      expect(await screen.findByText('H2D')).toBeInTheDocument();
+      expect(screen.queryByText('Token printer')).not.toBeInTheDocument();
+      expect(screen.getByText('Nozzle 2')).toBeInTheDocument();
+      expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
+      const camera = screen.getByAltText('Camera stream');
+      expect(camera).toHaveAttribute('src', expect.stringContaining('token=bblt_test'));
+      expect(camera).toHaveStyle({ transform: 'translate(-50%, -50%) rotate(90deg)' });
+      expect(camera.closest('.updated-overlay')).toHaveAttribute('data-size', 'large');
+      expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
+    });
+
+    it.each([
+      [90, { width: '640px', height: '360px' }],
+      [0, null],
+    ])('sizes a camera turned %i degrees from its slot, not the viewport', async (rotation, expected) => {
+      // A portrait source: the camera fills a 360 x 640 middle row, so the
+      // viewport (the old vw/vh fallback) would be the wrong box to swap.
+      const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+        return this.classList.contains('updated-overlay__camera')
+          ? ({ width: 360, height: 640, top: 0, left: 0, right: 360, bottom: 640, x: 0, y: 0, toJSON: () => ({}) } as DOMRect)
+          : ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect);
+      });
+      try {
+        server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, camera_rotation: rotation })));
+        renderOverlayPage(1, '?artwork=2');
+        const camera = await screen.findByAltText('Camera stream');
+        await waitFor(() => expect(camera).toHaveStyle({ transform: `translate(-50%, -50%) rotate(${rotation}deg)` }));
+        if (expected) {
+          await waitFor(() => expect(camera).toHaveStyle(expected));
+        } else {
+          expect(camera.style.width).toBe('');
+          expect(camera.style.height).toBe('');
+        }
+      } finally {
+        rect.mockRestore();
+      }
+    });
+
+    it('renders selected fields and accessible progress', async () => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
+      renderOverlayPage(1, '?artwork=2&show=printer,model,filename,status,progress,layers,eta');
+      expect(await screen.findByRole('progressbar')).toHaveAttribute('aria-valuenow', '45');
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      expect(screen.getByText('X1C')).toBeInTheDocument();
+      expect(screen.getByText('Benchy')).toBeInTheDocument();
+      expect(screen.getByText('150 / 300')).toBeInTheDocument();
+    });
+    it('hides all unselected information, including idle status', async () => {
+      const { container } = renderOverlayPage(1, '?artwork=2&show=&camera=false');
+      await screen.findByAltText('Bambuddy');
+      expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
+      expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
+      expect(container.querySelector('.updated-overlay__panel')).not.toBeInTheDocument();
+    });
+    it.each(['FINISH', 'FAILED'])('shows %s without contradictory idle text', async (state) => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, state })));
+      renderOverlayPage(1, '?artwork=2&show=status,progress');
+      expect(await screen.findByText(state === 'FINISH' ? 'Finished' : 'Failed')).toBeInTheDocument();
+      expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
+      expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
+    });
+    it('hides stale progress when disconnected', async () => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, connected: false })));
+      renderOverlayPage(1, '?artwork=2&show=status,progress,layers,eta');
+      expect(await screen.findByText('Printer offline')).toBeInTheDocument();
+      expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
+    });
+  });
+
   describe('rendering', () => {
     it('renders overlay page for printer', async () => {
       renderOverlayPage(1);

+ 20 - 1
frontend/src/components/StreamOverlayBuilder.tsx

@@ -47,6 +47,9 @@ export function StreamOverlayBuilder() {
   const [fields, setFields] = useState<string[]>(DEFAULT_FIELDS);
   const [size, setSize] = useState<OverlaySize>('medium');
   const [fps, setFps] = useState(DEFAULT_FPS);
+  // '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.
+  const [artwork, setArtwork] = useState<'1' | '2'>('1');
   const [showCamera, setShowCamera] = useState(true);
   const [token, setToken] = useState('');
   const [preview, setPreview] = useState(false);
@@ -80,10 +83,11 @@ export function StreamOverlayBuilder() {
     params.set('show', selected.join(','));
     if (size !== 'medium') params.set('size', size);
     if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
+    if (artwork !== '1') params.set('artwork', artwork);
     if (!showCamera) params.set('camera', 'false');
     if (token.trim()) params.set('token', token.trim());
     return `${window.location.origin}/overlay/${id}?${params.toString()}`;
-  }, [printerId, fields, size, fps, showCamera, token]);
+  }, [printerId, fields, size, fps, showCamera, token, artwork]);
 
   const toggleField = (key: string) => {
     setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
@@ -162,6 +166,21 @@ export function StreamOverlayBuilder() {
           </select>
         </div>
 
+        <div>
+          <label htmlFor="overlay-builder-artwork" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.artwork', 'Artwork')}
+          </label>
+          <select
+            id="overlay-builder-artwork"
+            value={artwork}
+            onChange={(e) => setArtwork(e.target.value as '1' | '2')}
+            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"
+          >
+            <option value="1">{t('streamOverlay.builder.artworkClassic', 'Classic')}</option>
+            <option value="2">{t('streamOverlay.builder.artworkV2', 'Version 2')}</option>
+          </select>
+        </div>
+
         <div>
           <label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
             {t('streamOverlay.builder.fps', 'Frame rate')}

+ 245 - 0
frontend/src/components/UpdatedStreamOverlay.css

@@ -0,0 +1,245 @@
+/* Scoped to the opt-in renderer. Existing OBS sources retain their styling. */
+.updated-overlay {
+  --overlay-scale: 1;
+  --overlay-space: clamp(12px, 2.5vmin, 32px);
+  position: relative;
+  isolation: isolate;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  min-height: 100vh;
+  color: #fff;
+  background: #10171b;
+  font-size: calc(clamp(14px, 2.2vmin, 28px) * var(--overlay-scale));
+  line-height: 1.3;
+  font-variant-numeric: tabular-nums;
+}
+.updated-overlay[data-size='small'] {
+  --overlay-scale: 0.8;
+}
+.updated-overlay[data-size='large'] {
+  --overlay-scale: 1.2;
+}
+.updated-overlay__header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: var(--overlay-space);
+  padding: var(--overlay-space);
+  z-index: 1;
+}
+.updated-overlay__identity {
+  display: flex;
+  align-items: center;
+  gap: 0.7em;
+  min-width: 0;
+  padding: 0.65em 0.9em;
+  border-radius: 0.7em;
+  background: rgb(12 19 23 / 90%);
+}
+.updated-overlay__identity svg {
+  flex-shrink: 0;
+  width: 2em;
+  height: 2em;
+}
+.updated-overlay__identity div {
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+.updated-overlay__identity strong {
+  display: block;
+  font-size: 1.35em;
+}
+.updated-overlay__identity span {
+  display: block;
+  color: #b8c0c7;
+}
+.updated-overlay__logo {
+  flex-shrink: 0;
+  margin-left: auto;
+}
+.updated-overlay__logo img {
+  width: clamp(100px, 19vmin, 230px);
+  height: auto;
+}
+.updated-overlay__camera {
+  position: absolute;
+  inset: 0;
+  z-index: -1;
+  overflow: hidden;
+}
+.updated-overlay__camera img {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 100%;
+  height: 100%;
+  max-width: none;
+  object-fit: cover;
+}
+.updated-overlay__panel {
+  padding: var(--overlay-space);
+  background: rgb(12 19 23 / 88%);
+}
+.updated-overlay__summary {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1em;
+  margin-bottom: 0.8em;
+}
+.updated-overlay__summary h1 {
+  font-size: 1.65em;
+  font-weight: 700;
+  line-height: 1.2;
+  overflow-wrap: anywhere;
+  min-width: 0;
+}
+.updated-overlay__status {
+  display: flex;
+  align-items: center;
+  gap: 0.5em;
+  font-size: 1.15em;
+  font-weight: 600;
+  color: #b8c0c7;
+}
+.updated-overlay__status > span {
+  width: 0.75em;
+  height: 0.75em;
+  border-radius: 50%;
+  background: currentColor;
+  flex-shrink: 0;
+}
+.updated-overlay[data-state='RUNNING'] .updated-overlay__status,
+.updated-overlay[data-state='FINISH'] .updated-overlay__status {
+  color: #00d76a;
+}
+.updated-overlay[data-state='PAUSE'] .updated-overlay__status {
+  color: #fbbf24;
+}
+.updated-overlay[data-state='FAILED'] .updated-overlay__status {
+  color: #f87171;
+}
+.updated-overlay__progress {
+  display: flex;
+  align-items: center;
+  gap: 1em;
+}
+.updated-overlay__progress > span {
+  color: #b8c0c7;
+  font-size: 0.8em;
+  text-transform: uppercase;
+}
+.updated-overlay__progress > strong {
+  color: #00d76a;
+  font-size: 1.4em;
+}
+.updated-overlay__progress > div {
+  flex: 1;
+  height: 0.65em;
+  border-radius: 1em;
+  overflow: hidden;
+  background: #3c454c;
+}
+.updated-overlay__progress > div > div {
+  height: 100%;
+  border-radius: inherit;
+  background: #00d76a;
+  transition: width 0.5s;
+}
+.updated-overlay__readings {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 1em;
+  margin-top: 0.8em;
+  padding-top: 0.8em;
+  border-top: 1px solid #ffffff35;
+}
+.updated-overlay__stats,
+.updated-overlay__temperatures {
+  display: flex;
+  flex: 1 1 auto;
+  flex-wrap: wrap;
+  gap: 1em;
+}
+.updated-overlay__reading {
+  display: flex;
+  align-items: center;
+  gap: 0.6em;
+  flex: 1 1 auto;
+  min-width: 0;
+}
+.updated-overlay__reading svg {
+  width: 1.8em;
+  height: 1.8em;
+  flex-shrink: 0;
+}
+.updated-overlay__reading > div > span {
+  display: block;
+  color: #b8c0c7;
+  font-size: 0.7em;
+  text-transform: uppercase;
+}
+.updated-overlay__reading strong {
+  display: block;
+  font-size: 1.3em;
+  overflow-wrap: anywhere;
+}
+.updated-overlay__reading small {
+  font-size: 0.65em;
+  color: #b8c0c7;
+}
+@media (orientation: portrait) {
+  .updated-overlay {
+    display: grid;
+    font-size: calc(clamp(14px, 3vmin, 36px) * var(--overlay-scale));
+    grid-template-columns: minmax(0, 1fr);
+    grid-template-rows: auto 1fr auto;
+    min-height: 100vh;
+  }
+  .updated-overlay__header {
+    grid-row: 1;
+    align-items: center;
+    background: #10171b;
+  }
+  .updated-overlay__identity {
+    background: none;
+    padding: 0;
+  }
+  .updated-overlay__camera {
+    position: relative;
+    grid-row: 2;
+    min-height: 30vh;
+    z-index: 0;
+  }
+  .updated-overlay__panel {
+    grid-row: 3;
+    background: #10171b;
+  }
+  .updated-overlay__summary {
+    flex-direction: column-reverse;
+    align-items: stretch;
+    gap: 0.65em;
+  }
+  .updated-overlay__progress {
+    flex-wrap: wrap;
+    justify-content: space-between;
+    gap: 0.3em;
+  }
+  .updated-overlay__progress > div {
+    flex: 0 0 100%;
+    order: 1;
+  }
+  .updated-overlay__readings {
+    flex-direction: column;
+  }
+  .updated-overlay__temperatures:not(:first-child) {
+    border-top: 1px solid #ffffff35;
+    padding-top: 0.8em;
+  }
+}
+@media (prefers-reduced-motion: reduce) {
+  .updated-overlay__progress > div > div {
+    transition: none;
+  }
+}

+ 173 - 0
frontend/src/components/UpdatedStreamOverlay.tsx

@@ -0,0 +1,173 @@
+import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Clock, Layers, Printer, Timer } from 'lucide-react';
+import './UpdatedStreamOverlay.css';
+
+interface UpdatedStreamOverlayProps {
+  size: 'small' | 'medium' | 'large';
+  camera: { url: string; rotation: number; onError: () => void } | null;
+  name: string | null;
+  model: string | null;
+  filename: string | null;
+  status: string | null;
+  state: string | null;
+  progress: number | null;
+  layers: string | null;
+  remaining: string | null;
+  eta: string | null;
+  temperatures: { key: string; icon: ReactNode; label: string; current: number; target: number | null }[];
+}
+
+// The camera slot's size in px, measured only while the camera is turned
+// sideways. Measured rather than written as 100cqh/100cqw because container
+// units need Chromium 105+, which older OBS browser sources don't have, and
+// the viewport units they would fall back to are wrong for the portrait
+// layout, where the camera fills only the middle row.
+function useCameraBox(ref: RefObject<HTMLDivElement | null>, active: boolean) {
+  const [box, setBox] = useState<{ width: number; height: number } | null>(null);
+  // Layout effect: measured before paint, so a sideways camera never shows a
+  // frame at the unswapped size.
+  useLayoutEffect(() => {
+    const el = ref.current;
+    if (!active || !el) return;
+    const measure = () => {
+      const { width, height } = el.getBoundingClientRect();
+      if (width > 0 && height > 0) {
+        setBox((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height }));
+      }
+    };
+    measure();
+    if (typeof ResizeObserver === 'undefined') {
+      window.addEventListener('resize', measure);
+      return () => window.removeEventListener('resize', measure);
+    }
+    const observer = new ResizeObserver(measure);
+    observer.observe(el);
+    return () => observer.disconnect();
+  }, [ref, active]);
+  return active ? box : null;
+}
+
+export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
+  const { t } = useTranslation();
+  const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } =
+    props;
+  const hasPanel =
+    filename || status || progress != null || layers || remaining || eta || temperatures.length > 0;
+  const rotation = camera?.rotation ?? 0;
+  const sideways = Math.abs(rotation % 180) === 90;
+  const cameraRef = useRef<HTMLDivElement>(null);
+  const cameraBox = useCameraBox(cameraRef, sideways && camera != null);
+  const stats = [
+    { key: 'layers', icon: <Layers />, label: t('streamOverlay.layer'), value: layers },
+    { key: 'remaining', icon: <Timer />, label: t('streamOverlay.remaining'), value: remaining },
+    { key: 'eta', icon: <Clock />, label: t('streamOverlay.eta'), value: eta },
+  ].filter((stat) => stat.value != null);
+
+  return (
+    <div className="updated-overlay" data-size={props.size} data-state={state}>
+      <header className="updated-overlay__header">
+        {(name || model) && (
+          <div className="updated-overlay__identity">
+            <Printer aria-hidden="true" />
+            <div>
+              {name && <strong>{name}</strong>}
+              {model && <span>{model}</span>}
+            </div>
+          </div>
+        )}
+        <a
+          className="updated-overlay__logo"
+          href="https://github.com/maziggy/bambuddy"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <img src="/img/bambuddy_logo_powered_by.png" alt="Bambuddy" />
+        </a>
+      </header>
+      {camera && (
+        <div className="updated-overlay__camera" ref={cameraRef}>
+          <img
+            key={camera.url}
+            src={camera.url}
+            alt={t('streamOverlay.cameraStream')}
+            style={{
+              transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
+              // A quarter turn swaps the image's axes, so give it the slot's
+              // height as width and vice versa; cover then still fills the slot.
+              ...(sideways && cameraBox ? { width: cameraBox.height, height: cameraBox.width } : {}),
+            }}
+            onError={camera.onError}
+          />
+        </div>
+      )}
+      {hasPanel && (
+        <section className="updated-overlay__panel">
+          {(filename || status) && (
+            <div className="updated-overlay__summary">
+              {filename && <h1>{filename}</h1>}
+              {status && (
+                <div className="updated-overlay__status">
+                  <span aria-hidden="true" />
+                  {status}
+                </div>
+              )}
+            </div>
+          )}
+          {progress != null && (
+            <div className="updated-overlay__progress">
+              <span>{t('streamOverlay.progress')}</span>
+              <div
+                role="progressbar"
+                aria-label={t('streamOverlay.progress')}
+                aria-valuemin={0}
+                aria-valuemax={100}
+                aria-valuenow={progress}
+              >
+                <div style={{ width: `${progress}%` }} />
+              </div>
+              <strong>{Math.round(progress)}%</strong>
+            </div>
+          )}
+          {(stats.length > 0 || temperatures.length > 0) && (
+            <div className="updated-overlay__readings">
+              {stats.length > 0 && (
+                <div className="updated-overlay__stats">
+                  {stats.map((stat) => (
+                    <div className="updated-overlay__reading" key={stat.key}>
+                      {stat.icon}
+                      <div>
+                        <span>{stat.label}</span>
+                        <strong>{stat.value}</strong>
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+              {temperatures.length > 0 && (
+                <div className="updated-overlay__temperatures">
+                  {temperatures.map((reading) => (
+                    <div className="updated-overlay__reading" key={reading.key}>
+                      {reading.icon}
+                      <div>
+                        <span>{reading.label}</span>
+                        <strong>
+                          {Math.round(reading.current)}°C
+                          {reading.target != null &&
+                            reading.target > 0 &&
+                            Math.round(reading.target) !== Math.round(reading.current) && (
+                              <small> / {Math.round(reading.target)}°C</small>
+                            )}
+                        </strong>
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+          )}
+        </section>
+      )}
+    </div>
+  );
+}

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

@@ -3596,7 +3596,12 @@ export default {
     eta: 'ETA',
     printerIdle: 'Drucker ist inaktiv',
     printerOffline: 'Drucker offline',
+    layer: 'Schicht',
+    remaining: 'Verbleibend',
     builder: {
+      artwork: 'Gestaltung',
+      artworkClassic: 'Klassisch',
+      artworkV2: 'Version 2',
       title: 'Stream-Overlay',
       description: 'Erstellt die URL für ein Stream-Overlay — eine bildschirmfüllende Kameraansicht mit eingeblendeten Live-Druckdaten, für OBS, ein Wanddisplay oder jede andere Browserquelle. Felder auswählen und URL kopieren.',
       printer: 'Drucker',

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

@@ -3626,7 +3626,12 @@ export default {
     eta: 'ETA',
     printerIdle: 'Printer is idle',
     printerOffline: 'Printer offline',
+    layer: 'Layer',
+    remaining: 'Remaining',
     builder: {
+      artwork: 'Artwork',
+      artworkClassic: 'Classic',
+      artworkV2: 'Version 2',
       title: 'Streaming Overlay',
       description: '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.',
       printer: 'Printer',

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

@@ -3598,7 +3598,12 @@ export default {
     eta: 'Tiempo estimado',
     printerIdle: 'La impresora está inactiva',
     printerOffline: 'Impresora desconectada',
+    layer: 'Capa',
+    remaining: 'Restante',
     builder: {
+      artwork: 'Diseño',
+      artworkClassic: 'Clásico',
+      artworkV2: 'Versión 2',
       title: 'Superposición de emisión',
       description: 'Crea la URL de una superposición de emisión: una vista de cámara a pantalla completa con los datos de impresión en directo encima, para OBS, una pantalla de pared o cualquier fuente de navegador. Elige los campos y copia la URL.',
       printer: 'Impresora',

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

@@ -3584,7 +3584,12 @@ export default {
     eta: 'Fin estimée',
     printerIdle: 'Imprimante inactive',
     printerOffline: 'Imprimante hors ligne',
+    layer: 'Couche',
+    remaining: 'Temps restant',
     builder: {
+      artwork: 'Habillage',
+      artworkClassic: 'Classique',
+      artworkV2: 'Version 2',
       title: 'Incrustation de diffusion',
       description: 'Compose l\'URL d\'une incrustation de diffusion — une vue caméra plein écran avec les données d\'impression en direct par-dessus, pour OBS, un écran mural ou toute source navigateur. Choisissez les champs voulus et copiez l\'URL.',
       printer: 'Imprimante',

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

@@ -3583,7 +3583,12 @@ export default {
     eta: 'ETA',
     printerIdle: 'Stampante inattiva',
     printerOffline: 'Stampante offline',
+    layer: 'Strato',
+    remaining: 'Tempo rimanente',
     builder: {
+      artwork: 'Grafica',
+      artworkClassic: 'Classica',
+      artworkV2: 'Versione 2',
       title: 'Overlay per streaming',
       description: 'Compone l\'URL di un overlay per streaming: una vista telecamera a schermo intero con i dati di stampa in tempo reale sovrapposti, per OBS, un display a parete o qualsiasi sorgente browser. Scegli i campi e copia l\'URL.',
       printer: 'Stampante',

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

@@ -3596,7 +3596,12 @@ export default {
     eta: '残り時間',
     printerIdle: 'プリンター待機中',
     printerOffline: 'プリンターオフライン',
+    layer: 'レイヤー',
+    remaining: '残り時間',
     builder: {
+      artwork: 'デザイン',
+      artworkClassic: 'クラシック',
+      artworkV2: 'バージョン 2',
       title: 'ストリームオーバーレイ',
       description: 'ストリームオーバーレイのURLを作成します。全画面のカメラ映像に印刷中の情報を重ねて表示するもので、OBSや壁掛けディスプレイなどのブラウザソースで使えます。表示する項目を選んでURLをコピーしてください。',
       printer: 'プリンター',

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

@@ -3419,7 +3419,12 @@ export default {
     eta: '예상 완료',
     printerIdle: '프린터 대기 중',
     printerOffline: '프린터 오프라인',
+    layer: '레이어',
+    remaining: '남은 시간',
     builder: {
+      artwork: '디자인',
+      artworkClassic: '클래식',
+      artworkV2: '버전 2',
       title: '스트림 오버레이',
       description: '스트림 오버레이 URL을 만듭니다. 전체 화면 카메라 영상 위에 실시간 출력 정보를 겹쳐 보여주며 OBS, 벽걸이 디스플레이 등 모든 브라우저 소스에서 쓸 수 있습니다. 원하는 항목을 고르고 URL을 복사하세요.',
       printer: '프린터',

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

@@ -3626,7 +3626,12 @@ export default {
     eta: 'ETA',
     printerIdle: 'Printer is inactief',
     printerOffline: 'Printer offline',
+    layer: 'Laag',
+    remaining: 'Resterend',
     builder: {
+      artwork: 'Vormgeving',
+      artworkClassic: 'Klassiek',
+      artworkV2: 'Versie 2',
       title: 'Streamingoverlay',
       description: 'Bouw de URL voor een streamingoverlay — een camerabeeld op volledig scherm met live afdrukgegevens eroverheen, voor OBS, een wanddisplay of elke browserbron. Kies de velden die je wilt en kopieer de URL.',
       printer: 'Printer',

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

@@ -3583,7 +3583,12 @@ export default {
     eta: 'ETA',
     printerIdle: 'Impressora ociosa',
     printerOffline: 'Impressora offline',
+    layer: 'Camada',
+    remaining: 'Tempo restante',
     builder: {
+      artwork: 'Visual',
+      artworkClassic: 'Clássico',
+      artworkV2: 'Versão 2',
       title: 'Sobreposição de transmissão',
       description: 'Monta a URL de uma sobreposição de transmissão: a câmera em tela cheia com os dados da impressão sobrepostos, para OBS, um painel de parede ou qualquer fonte de navegador. Escolha os campos e copie a URL.',
       printer: 'Impressora',

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

@@ -3411,7 +3411,12 @@ export default {
     eta: "Осталось",
     printerIdle: "Принтер простаивает",
     printerOffline: "Принтер не в сети",
+    layer: 'Слой',
+    remaining: 'Осталось',
     builder: {
+      artwork: 'Оформление',
+      artworkClassic: 'Классическое',
+      artworkV2: 'Версия 2',
       title: "Оформление трансляции",
       description: "Собирает адрес оформления трансляции: полноэкранное изображение камеры с наложенными данными о печати — для OBS, настенного экрана или любого источника-браузера. Выберите нужные поля и скопируйте адрес.",
       printer: "Принтер",

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

@@ -3625,7 +3625,12 @@ errors: {
     eta: 'Beräknad tid',
     printerIdle: 'Skrivaren är inaktiv',
     printerOffline: 'Skrivare offline',
+    layer: 'Lager',
+    remaining: 'Återstående',
     builder: {
+      artwork: 'Utseende',
+      artworkClassic: 'Klassisk',
+      artworkV2: 'Version 2',
       title: 'Strömningsöverlägg',
       description: 'Bygg URL:en för ett strömningsöverlägg — en helskärmskameravy med live utskriftsdata som visas ovanpå. Välj de fält du vill ha och kopiera URL:en.',
       printer: 'Skrivare',

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

@@ -3598,7 +3598,12 @@ export default {
     eta: 'ETA',
     printerIdle: 'Yazıcı boşta',
     printerOffline: 'Yazıcı çevrimdışı',
+    layer: 'Katman',
+    remaining: 'Kalan süre',
     builder: {
+      artwork: 'Tasarım',
+      artworkClassic: 'Klasik',
+      artworkV2: 'Sürüm 2',
       title: 'Yayın Kaplaması',
       description: 'Yayın kaplaması için URL oluşturur: tam ekran kamera görüntüsünün üzerine canlı baskı bilgileri bindirilir; OBS, duvar ekranı veya herhangi bir tarayıcı kaynağı için. İstediğiniz alanları seçip URL\'yi kopyalayın.',
       printer: 'Yazıcı',

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

@@ -3623,7 +3623,12 @@ export default {
     eta: "ETA",
     printerIdle: "Принтер неактивний",
     printerOffline: "Принтер не в мережі",
+    layer: 'Шар',
+    remaining: 'Залишилось',
     builder: {
+      artwork: 'Оформлення',
+      artworkClassic: 'Класичне',
+      artworkV2: 'Версія 2',
       title: "Оформлення трансляції",
       description: "Складає адресу оформлення трансляції: повноекранне зображення камери з накладеними даними друку — для OBS, настінного екрана або будь-якого джерела-браузера. Виберіть потрібні поля та скопіюйте адресу.",
       printer: "Принтер",

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

@@ -3583,7 +3583,12 @@ export default {
     eta: '预计完成时间',
     printerIdle: '打印机空闲',
     printerOffline: '打印机离线',
+    layer: '层数',
+    remaining: '剩余时间',
     builder: {
+      artwork: '外观',
+      artworkClassic: '经典',
+      artworkV2: '第 2 版',
       title: '直播叠加层',
       description: '生成直播叠加层的网址:全屏摄像头画面上叠加实时打印信息,可用于 OBS、墙面显示屏或任何浏览器源。选择需要的字段并复制网址。',
       printer: '打印机',

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

@@ -3583,7 +3583,12 @@ export default {
     eta: '預計完成時間',
     printerIdle: '印表機空閒',
     printerOffline: '印表機離線',
+    layer: '層數',
+    remaining: '剩餘時間',
     builder: {
+      artwork: '外觀',
+      artworkClassic: '經典',
+      artworkV2: '第 2 版',
       title: '直播疊加層',
       description: '產生直播疊加層的網址:全螢幕攝影機畫面上疊加即時列印資訊,可用於 OBS、牆面顯示器或任何瀏覽器來源。選擇需要的欄位並複製網址。',
       printer: '印表機',

+ 23 - 0
frontend/src/pages/StreamOverlayPage.tsx

@@ -3,6 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react';
+import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 import { mapModelCode } from '../utils/printerModel';
@@ -13,6 +14,7 @@ type OverlaySize = 'small' | 'medium' | 'large';
 
 interface OverlayConfig {
   size: OverlaySize;
+  updatedArtwork: boolean;
   fps: number;
   showCamera: boolean;
   showProgress: boolean;
@@ -54,6 +56,7 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
   return {
     size: (params.get('size') as OverlaySize) || 'medium',
     fps,
+    updatedArtwork: params.get('artwork') === '2',
     showCamera,
     showProgress: show.includes('progress'),
     showLayers: show.includes('layers'),
@@ -380,6 +383,26 @@ export function StreamOverlayPage() {
     ? `${camPath}&token=${encodeURIComponent(token)}`
     : withStreamToken(camPath);
 
+  if (config.updatedArtwork) {
+    const active = status.connected && isPrinting;
+    const remainingTime = status.remaining_time;
+    const hasRemaining = active && config.showEta && remainingTime != null && remainingTime > 0;
+    return <UpdatedStreamOverlay
+      size={config.size}
+      camera={config.showCamera ? { url: streamUrl, rotation: printer?.camera_rotation ?? 0, onError: handleStreamError } : null}
+      name={config.showPrinter ? printer?.name ?? null : null}
+      model={config.showModel ? mapModelCode(printer?.model ?? null) || null : null}
+      filename={config.showFilename && status.current_print ? formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t) : null}
+      status={config.showStatus ? (status.connected ? getStatusText(status, t) : t('streamOverlay.printerOffline')) : null}
+      state={status.connected ? status.state : null}
+      progress={active && config.showProgress ? Math.min(100, Math.max(0, Number.isFinite(progress) ? progress : 0)) : null}
+      layers={active && config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 ? `${status.layer_num} / ${status.total_layers}` : null}
+      remaining={hasRemaining ? formatDuration(remainingTime * 60) : null}
+      eta={hasRemaining ? formatETA(remainingTime, timeFormat, t) : null}
+      temperatures={status.connected ? tempReadings : []}
+    />;
+  }
+
   return (
     <div className="min-h-screen bg-black relative overflow-hidden">
       {/* Camera feed - fullscreen background (optional) */}