diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx index 502435c8..a5905916 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx @@ -252,6 +252,19 @@ describe('CameraPreviewPanel', () => { expect(screen.getByText('viewers 2')).toBeTruthy() }) + it('can hide push-to-talk for compact live camera cards', () => { + // Given: A ready preview rendered in a compact Live card context + mockReadyPreviewSession() + + // When: The preview panel opts out of talk controls + render() + + // Then: Preview controls remain while push-to-talk is not mounted + expect(screen.getByRole('button', { name: 'Attach preview' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Hold to talk' })).toBeNull() + expect(usePushToTalkMock).not.toHaveBeenCalled() + }) + it('initializes hls.js playback when a playlist URL becomes ready', async () => { // Given: A ready preview session with a tokenized playlist URL mockReadyPreviewSession() diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.tsx index c8a99518..1814e9f8 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.tsx @@ -28,6 +28,10 @@ type WebKitVideoElement = HTMLVideoElement & { interface CameraPreviewPanelProps { cameraName: string + title?: string + subtitle?: string + showTalkControl?: boolean + className?: string } function previewTone( @@ -103,7 +107,13 @@ async function exitActiveFullscreen(): Promise { } } -export function CameraPreviewPanel({ cameraName }: CameraPreviewPanelProps) { +export function CameraPreviewPanel({ + cameraName, + title = 'Live preview', + subtitle = 'On-demand HLS stream from the active runtime.', + showTalkControl = true, + className, +}: CameraPreviewPanelProps) { const { error, isPending, @@ -390,11 +400,11 @@ export function CameraPreviewPanel({ cameraName }: CameraPreviewPanelProps) { }, [error, playerError, playlistReady, playlistUrl, status?.enabled, warning]) return ( - + - Live preview - On-demand HLS stream from the active runtime. + {title} + {subtitle} {previewLabel(effectiveState)} @@ -438,7 +448,7 @@ export function CameraPreviewPanel({ cameraName }: CameraPreviewPanelProps) { {statusMessage} ) : null} - + {showTalkControl ? : null} + ) + } + + return ( + + ) +} + +export function LiveCameraCard({ camera }: LiveCameraCardProps) { + return ( + + {cameraStatusLabel(camera)} + + } + media={renderPreview(camera)} + meta={[ + { label: 'Last seen', value: formatLastSeen(camera.last_heartbeat) }, + ]} + technicalDetails={ + + + + Source + {camera.source_backend} + + + Enabled + {camera.enabled ? 'true' : 'false'} + + + + } + actions={ + <> + + View Events + + + Camera controls + + > + } + /> + ) +} diff --git a/ui/src/features/live/LivePage.test.tsx b/ui/src/features/live/LivePage.test.tsx new file mode 100644 index 00000000..69a216d1 --- /dev/null +++ b/ui/src/features/live/LivePage.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import type { CameraResponse } from '../../api/generated/types' +import { LivePage } from './LivePage' + +const useCamerasQueryMock = vi.fn() +const useSetupRedirectMock = vi.fn() + +vi.mock('../../api/hooks/useCamerasQuery', () => ({ + useCamerasQuery: () => useCamerasQueryMock(), +})) + +vi.mock('../setup/useSetupRedirect', () => ({ + useSetupRedirect: () => useSetupRedirectMock(), +})) + +vi.mock('../cameras/components/CameraPreviewPanel', () => ({ + CameraPreviewPanel: ({ + cameraName, + showTalkControl, + title, + }: { + cameraName: string + showTalkControl?: boolean + title?: string + }) => ( + + {title} for {cameraName} + + ), +})) + +function makeCamera(overrides: Partial = {}): CameraResponse { + return { + name: 'front', + enabled: true, + healthy: true, + source_backend: 'rtsp', + last_heartbeat: 1_739_590_400, + source_config: {}, + ...overrides, + } +} + +function renderLivePage(cameras: CameraResponse[]) { + useSetupRedirectMock.mockReturnValue({ isChecking: false, shouldRedirect: false }) + useCamerasQueryMock.mockReturnValue({ + data: cameras, + isPending: false, + isFetching: false, + error: null, + refetch: vi.fn().mockResolvedValue(undefined), + }) + + render( + + + , + ) +} + +describe('LivePage camera cards', () => { + beforeEach(() => { + useCamerasQueryMock.mockReset() + useSetupRedirectMock.mockReset() + }) + + afterEach(() => { + cleanup() + }) + + it('renders a camera-first grid with status, preview, and event actions', () => { + // Given: Existing camera API data includes an RTSP camera + renderLivePage([makeCamera({ name: 'front_door' })]) + + // When: The Live page renders camera cards + const card = screen.getByRole('heading', { name: 'front_door' }).closest('article') + + // Then: The card exposes status, preview, and event navigation using existing data + expect(card ? within(card).getByText('Online') : null).toBeTruthy() + expect(card ? within(card).getByTestId('preview-front_door') : null).toBeTruthy() + expect(screen.getByTestId('preview-front_door').getAttribute('data-talk')).toBe('false') + expect(card ? within(card).getByText('Last seen') : null).toBeTruthy() + expect( + card ? within(card).getByRole('link', { name: 'View Events' }).getAttribute('href') : null, + ).toBe('/events?camera=front_door') + expect( + card + ? within(card).getByRole('link', { name: 'Camera controls' }).getAttribute('href') + : null, + ).toBe('/cameras') + }) + + it('uses lightweight placeholders for cameras without live preview support', () => { + // Given: Existing camera data includes a non-RTSP source and a disabled camera + renderLivePage([ + makeCamera({ name: 'dropbox_uploads', source_backend: 'local_folder' }), + makeCamera({ name: 'garage', enabled: false, healthy: false }), + ]) + + // When: The Live page renders the cards + const disabledCard = screen.getByRole('heading', { name: 'garage' }).closest('article') + + // Then: The list does not auto-start preview streams and still shows clear status + expect(screen.getByText('Live preview is not available for this camera source.')).toBeTruthy() + expect(disabledCard ? within(disabledCard).getByText('Disabled') : null).toBeTruthy() + expect( + disabledCard + ? within(disabledCard).getByText('Enable this camera to use live preview.') + : null, + ).toBeTruthy() + expect(screen.queryByTestId('preview-dropbox_uploads')).toBeNull() + }) +}) diff --git a/ui/src/features/live/LivePage.tsx b/ui/src/features/live/LivePage.tsx index 198f6629..048faf41 100644 --- a/ui/src/features/live/LivePage.tsx +++ b/ui/src/features/live/LivePage.tsx @@ -1,34 +1,15 @@ -import { createSearchParams, Link } from 'react-router-dom' +import { Link } from 'react-router-dom' import { clearApiKey, isUnauthorizedAPIError, saveApiKey } from '../../api/client' import { useCamerasQuery } from '../../api/hooks/useCamerasQuery' import { ApiKeyGate } from '../../components/ui/ApiKeyGate' import { Button } from '../../components/ui/Button' -import { CameraCard } from '../../components/ui/CameraCard' import { Card } from '../../components/ui/Card' import { EmptyState } from '../../components/ui/EmptyState' import { ResponsivePageShell } from '../../components/ui/ResponsivePageShell' -import { StatusBadge } from '../../components/ui/StatusBadge' import { describeCameraError } from '../cameras/presentation' import { useSetupRedirect } from '../setup/useSetupRedirect' - -function cameraStatusTone(enabled: boolean, healthy: boolean): 'healthy' | 'unknown' | 'unhealthy' { - if (!enabled) { - return 'unknown' - } - return healthy ? 'healthy' : 'unhealthy' -} - -function cameraStatusLabel(enabled: boolean, healthy: boolean): string { - if (!enabled) { - return 'Disabled' - } - return healthy ? 'Online' : 'Offline' -} - -function eventsSearch(cameraName: string): string { - return createSearchParams({ camera: cameraName }).toString() -} +import { LiveCameraCard } from './LiveCameraCard' export function LivePage() { const { shouldRedirect, isChecking } = useSetupRedirect() @@ -103,29 +84,7 @@ export function LivePage() { {cameras.length > 0 ? ( {cameras.map((camera) => ( - - {cameraStatusLabel(camera.enabled, camera.healthy)} - - } - actions={ - <> - - Camera controls - - - View Events - - > - } - /> + ))} ) : null} diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index bccbc98e..1b7cd893 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -549,6 +549,11 @@ a:hover { gap: var(--space-3); } +.live-camera-list { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); + align-items: start; +} + .settings-grid { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
Live preview
On-demand HLS stream from the active runtime.
{title}
{subtitle}
{statusMessage}