diff --git a/frontend/src/__tests__/hooks/useOverlayCameraRecovery.test.ts b/frontend/src/__tests__/hooks/useOverlayCameraRecovery.test.ts
new file mode 100644
index 000000000..9e2acb373
--- /dev/null
+++ b/frontend/src/__tests__/hooks/useOverlayCameraRecovery.test.ts
@@ -0,0 +1,77 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useOverlayCameraRecovery } from '../../hooks/useOverlayCameraRecovery';
+
+describe('useOverlayCameraRecovery', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('renews the connection periodically without an error event', () => {
+ const { result } = renderHook(() => useOverlayCameraRecovery(true));
+ const initial = result.current.imageKey;
+ act(() => vi.advanceTimersByTime(59_999));
+ expect(result.current.imageKey).toBe(initial);
+ act(() => vi.advanceTimersByTime(1));
+ expect(result.current.imageKey).not.toBe(initial);
+ act(() => vi.advanceTimersByTime(60_000));
+ expect(result.current.imageKey).toBe(initial + 2);
+ });
+
+ it('deduplicates image errors and retries after three seconds', () => {
+ const { result } = renderHook(() => useOverlayCameraRecovery(true));
+ const initial = result.current.imageKey;
+ act(() => { result.current.handleStreamError(); result.current.handleStreamError(); });
+ act(() => vi.advanceTimersByTime(2_999));
+ expect(result.current.imageKey).toBe(initial);
+ act(() => vi.advanceTimersByTime(1));
+ expect(result.current.imageKey).toBe(initial + 1);
+ });
+
+ it('gives a connection opened by an error retry a full renewal period', () => {
+ const { result } = renderHook(() => useOverlayCameraRecovery(true));
+ const initial = result.current.imageKey;
+ act(() => vi.advanceTimersByTime(56_000));
+ act(() => result.current.handleStreamError());
+ act(() => vi.advanceTimersByTime(3_000));
+ expect(result.current.imageKey).toBe(initial + 1);
+ act(() => vi.advanceTimersByTime(59_999));
+ expect(result.current.imageKey).toBe(initial + 1);
+ act(() => vi.advanceTimersByTime(1));
+ expect(result.current.imageKey).toBe(initial + 2);
+ });
+
+ it('cancels a pending error retry when periodic renewal happens first', () => {
+ const { result } = renderHook(() => useOverlayCameraRecovery(true));
+ const initial = result.current.imageKey;
+ act(() => vi.advanceTimersByTime(59_000));
+ act(() => result.current.handleStreamError());
+ act(() => vi.advanceTimersByTime(1_000));
+ expect(result.current.imageKey).toBe(initial + 1);
+ act(() => vi.advanceTimersByTime(3_000));
+ expect(result.current.imageKey).toBe(initial + 1);
+ });
+
+ it('does not schedule recovery while the camera is disabled', () => {
+ const { result } = renderHook(() => useOverlayCameraRecovery(false));
+ const initial = result.current.imageKey;
+ act(() => result.current.handleStreamError());
+ expect(vi.getTimerCount()).toBe(0);
+ act(() => vi.advanceTimersByTime(120_000));
+ expect(result.current.imageKey).toBe(initial);
+ });
+
+ it('cancels all recovery timers when hidden or unmounted', () => {
+ const { result, rerender, unmount } = renderHook(
+ ({ enabled }) => useOverlayCameraRecovery(enabled), { initialProps: { enabled: true } },
+ );
+ act(() => result.current.handleStreamError());
+ rerender({ enabled: false });
+ expect(vi.getTimerCount()).toBe(0);
+ act(() => result.current.handleStreamError());
+ expect(vi.getTimerCount()).toBe(0);
+ rerender({ enabled: true });
+ act(() => result.current.handleStreamError());
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
index 78d70e053..f25468d1d 100644
--- a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
+++ b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { act, screen, waitFor, render as rtlRender } from '@testing-library/react';
import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
@@ -106,6 +106,32 @@ describe('StreamOverlayPage', () => {
vi.unstubAllGlobals();
});
+ it.each(['', '&artwork=2'])('reconnects the kiosk camera without changing its token or settings (%s)', async (artwork) => {
+ server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
+ ...mockStatusIdle, camera_rotation: 90,
+ })));
+ const timers = vi.spyOn(globalThis, 'setTimeout');
+ const view = renderOverlayPage(1, `?token=overlay-test&fps=7${artwork}`);
+ try {
+ const image = await screen.findByAltText('Camera stream');
+ const source = image.getAttribute('src');
+ expect(source).toContain('token=overlay-test');
+ const renewal = timers.mock.calls.find(([, delay]) => delay === 60_000)?.[0];
+ expect(renewal).toBeTypeOf('function');
+ if (typeof renewal !== 'function') throw new Error('Missing camera renewal timer');
+ act(() => renewal());
+ const renewed = screen.getByAltText('Camera stream');
+ expect(renewed).not.toBe(image);
+ expect(renewed.getAttribute('src')).not.toBe(source);
+ expect(renewed.getAttribute('src')).toContain('token=overlay-test');
+ expect(renewed.getAttribute('src')).toContain('fps=7');
+ expect(renewed.style.transform).toBe(artwork ? 'translate(-50%, -50%) rotate(90deg)' : 'rotate(90deg)');
+ } finally {
+ view.unmount();
+ timers.mockRestore();
+ }
+ });
+
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' })));
diff --git a/frontend/src/hooks/useOverlayCameraRecovery.ts b/frontend/src/hooks/useOverlayCameraRecovery.ts
new file mode 100644
index 000000000..0e0ccf7dc
--- /dev/null
+++ b/frontend/src/hooks/useOverlayCameraRecovery.ts
@@ -0,0 +1,41 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+// MJPEG
connections can stop delivering frames without firing onError.
+// Renew only the camera connection, keeping the overlay and its credentials.
+// This also renews healthy connections; it does not measure frame freshness.
+const STREAM_RENEWAL_MS = 60_000;
+const ERROR_RETRY_MS = 3_000;
+
+export function useOverlayCameraRecovery(enabled: boolean) {
+ const [imageKey, setImageKey] = useState(Date.now);
+ const retryTimer = useRef | null>(null);
+
+ const renew = useCallback(() => {
+ if (retryTimer.current !== null) {
+ clearTimeout(retryTimer.current);
+ retryTimer.current = null;
+ }
+ setImageKey((key) => key + 1);
+ }, []);
+
+ useEffect(() => {
+ if (!enabled) return;
+ // Start a fresh deadline after each retry so a newly opened connection
+ // isn't immediately replaced by the previous connection's renewal timer.
+ const renewalTimer = setTimeout(renew, STREAM_RENEWAL_MS);
+ return () => {
+ clearTimeout(renewalTimer);
+ if (retryTimer.current !== null) {
+ clearTimeout(retryTimer.current);
+ retryTimer.current = null;
+ }
+ };
+ }, [enabled, imageKey, renew]);
+
+ const handleStreamError = useCallback(() => {
+ if (!enabled || retryTimer.current !== null) return;
+ retryTimer.current = setTimeout(renew, ERROR_RETRY_MS);
+ }, [enabled, renew]);
+
+ return { imageKey, handleStreamError };
+}
diff --git a/frontend/src/pages/StreamOverlayPage.tsx b/frontend/src/pages/StreamOverlayPage.tsx
index 944aa3873..d9350fc95 100644
--- a/frontend/src/pages/StreamOverlayPage.tsx
+++ b/frontend/src/pages/StreamOverlayPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -7,6 +7,7 @@ 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';
+import { useOverlayCameraRecovery } from '../hooks/useOverlayCameraRecovery';
type TFunction = (key: string, options?: Record) => string;
@@ -168,7 +169,6 @@ export function StreamOverlayPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const id = parseInt(printerId || '0', 10);
- const [imageKey, setImageKey] = useState(Date.now());
const config = useMemo(() => parseConfig(searchParams), [searchParams]);
const sizes = getSizeClasses(config.size);
@@ -224,6 +224,7 @@ export function StreamOverlayPage() {
config.showModel ? mapModelCode(printer?.model ?? null) : null,
].filter(Boolean).join(' ยท ');
const status = kiosk ? overlay : statusData;
+ const { imageKey, handleStreamError } = useOverlayCameraRecovery(id > 0 && config.showCamera && status != null);
const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
// WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
@@ -292,13 +293,6 @@ export function StreamOverlayPage() {
};
}, [printer, t]);
- // Refresh stream on error
- const handleStreamError = () => {
- setTimeout(() => {
- setImageKey(Date.now());
- }, 3000);
- };
-
if (!id) {
return (