From d5ee6994890ee69c5e72545f828bde8eb3a0690c Mon Sep 17 00:00:00 2001 From: Adam Spice Date: Fri, 2 Oct 2026 02:16:59 +1300 Subject: [PATCH] Renew overlay camera connections after silent stalls (#3213) --- .../hooks/useOverlayCameraRecovery.test.ts | 77 +++++++++++++++++++ .../pages/StreamOverlayPage.test.tsx | 28 ++++++- .../src/hooks/useOverlayCameraRecovery.ts | 41 ++++++++++ frontend/src/pages/StreamOverlayPage.tsx | 12 +-- 4 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 frontend/src/__tests__/hooks/useOverlayCameraRecovery.test.ts create mode 100644 frontend/src/hooks/useOverlayCameraRecovery.ts 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 (