Initial scaffold: React+TS+Vite frontend, FastAPI backend, config system
This commit is contained in:
13
frontend/src/App.tsx
Normal file
13
frontend/src/App.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
|
||||
export default function App() {
|
||||
const loadConfig = useConfigStore((s) => s.loadConfig);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
return <AppShell />;
|
||||
}
|
||||
72
frontend/src/components/alerts/AlertPopup.tsx
Normal file
72
frontend/src/components/alerts/AlertPopup.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAlertStore } from '@/stores/alertStore';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { CameraPlayer } from '@/components/player/CameraPlayer';
|
||||
|
||||
export function AlertPopup() {
|
||||
const { activeAlert, dismissAlert } = useAlertStore();
|
||||
const config = useConfigStore((s) => s.config);
|
||||
const cameras = useConfigStore((s) => s.enabledCameras());
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
|
||||
const autoDismiss = config?.alerts.auto_dismiss_seconds ?? 30;
|
||||
const camera = activeAlert ? cameras.find((c) => c.name === activeAlert.camera) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAlert) return;
|
||||
setCountdown(autoDismiss);
|
||||
|
||||
const dismissTimer = setTimeout(dismissAlert, autoDismiss * 1000);
|
||||
const countdownInterval = setInterval(() => {
|
||||
setCountdown((p) => Math.max(0, p - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(dismissTimer);
|
||||
clearInterval(countdownInterval);
|
||||
};
|
||||
}, [activeAlert, autoDismiss, dismissAlert]);
|
||||
|
||||
if (!activeAlert || !camera) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-dark-primary flex flex-col">
|
||||
{/* Alert header */}
|
||||
<div className="h-14 bg-status-error/10 border-b-2 border-status-error flex items-center justify-between px-5 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-status-error flex items-center justify-center animate-pulse">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Person Detected</h2>
|
||||
<p className="text-status-error text-sm">{camera.display_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-400">Auto-dismiss</div>
|
||||
<div className="text-lg font-semibold text-white">{countdown}s</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={dismissAlert}
|
||||
className="px-5 py-2 bg-accent hover:bg-accent/80 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Camera feed */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<CameraPlayer
|
||||
camera={camera}
|
||||
className="w-full max-w-5xl aspect-video ring-2 ring-status-error rounded-xl"
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/grid/CameraGrid.tsx
Normal file
35
frontend/src/components/grid/CameraGrid.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { CameraGridCell } from './CameraGridCell';
|
||||
|
||||
const STAGGER_MS = 200;
|
||||
|
||||
export function CameraGrid() {
|
||||
const cameras = useConfigStore((s) => s.enabledCameras());
|
||||
const gridConfig = useConfigStore((s) => s.config?.grid);
|
||||
|
||||
const count = cameras.length;
|
||||
const cols = gridConfig?.columns ?? (
|
||||
count <= 4 ? 2 :
|
||||
count <= 9 ? 3 :
|
||||
4
|
||||
);
|
||||
|
||||
const rows = Math.ceil(count / cols);
|
||||
const gap = gridConfig?.gap ?? 4;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full p-2"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||
gap: `${gap}px`,
|
||||
}}
|
||||
>
|
||||
{cameras.map((cam, i) => (
|
||||
<CameraGridCell key={cam.name} camera={cam} delayMs={i * STAGGER_MS} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/grid/CameraGridCell.tsx
Normal file
67
frontend/src/components/grid/CameraGridCell.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useStream } from '@/hooks/useStream';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import type { CameraConfig } from '@/types/config';
|
||||
|
||||
interface CameraGridCellProps {
|
||||
camera: CameraConfig;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) {
|
||||
const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? '');
|
||||
const setFullscreen = useUIStore((s) => s.setFullscreenCamera);
|
||||
|
||||
const { videoRef, isConnecting, error, retry } = useStream({
|
||||
streamName: camera.name,
|
||||
go2rtcUrl,
|
||||
delayMs,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-dark-tertiary rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-accent/50 transition-all"
|
||||
onClick={() => setFullscreen(camera.name)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Loading */}
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-gray-500">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
|
||||
<div className="flex flex-col items-center gap-2 p-2">
|
||||
<svg className="w-6 h-6 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-500">Offline</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); retry(); }} className="text-xs text-accent hover:underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-white/90 truncate block">
|
||||
{camera.display_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/layout/AppShell.tsx
Normal file
37
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useAlerts } from '@/hooks/useAlerts';
|
||||
import { Header } from './Header';
|
||||
import { CameraGrid } from '@/components/grid/CameraGrid';
|
||||
import { FullscreenView } from '@/components/player/FullscreenView';
|
||||
import { AlertPopup } from '@/components/alerts/AlertPopup';
|
||||
import { SettingsPage } from '@/components/settings/SettingsPage';
|
||||
|
||||
export function AppShell() {
|
||||
const { loading, error } = useConfigStore();
|
||||
const { view, fullscreenCamera } = useUIStore();
|
||||
|
||||
useAlerts();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-dark-primary">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-gray-400">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-dark-primary overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{view === 'settings' ? <SettingsPage /> : <CameraGrid />}
|
||||
</main>
|
||||
{fullscreenCamera && <FullscreenView />}
|
||||
<AlertPopup />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/layout/Header.tsx
Normal file
33
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function Header() {
|
||||
const title = useConfigStore((s) => s.config?.title ?? 'Camera Viewer');
|
||||
const { view, setView } = useUIStore();
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-4 shrink-0">
|
||||
<h1
|
||||
className="text-lg font-semibold text-white cursor-pointer"
|
||||
onClick={() => setView('grid')}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView(view === 'settings' ? 'grid' : 'settings')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
view === 'settings' ? 'bg-accent text-white' : 'text-gray-400 hover:text-white hover:bg-dark-hover'
|
||||
}`}
|
||||
aria-label="Settings"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/player/CameraPlayer.tsx
Normal file
57
frontend/src/components/player/CameraPlayer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useStream } from '@/hooks/useStream';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import type { CameraConfig } from '@/types/config';
|
||||
|
||||
interface CameraPlayerProps {
|
||||
camera: CameraConfig;
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function CameraPlayer({ camera, className = '', showLabel = true }: CameraPlayerProps) {
|
||||
const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? '');
|
||||
|
||||
const { videoRef, isConnecting, error, retry } = useStream({
|
||||
streamName: camera.name,
|
||||
go2rtcUrl,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`relative bg-dark-tertiary overflow-hidden ${className}`}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-400">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<svg className="w-10 h-10 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-400">{error}</span>
|
||||
<button onClick={retry} className="text-sm text-accent hover:underline">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLabel && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||||
<span className="text-sm font-medium">{camera.display_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/player/FullscreenView.tsx
Normal file
79
frontend/src/components/player/FullscreenView.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { CameraPlayer } from './CameraPlayer';
|
||||
|
||||
export function FullscreenView() {
|
||||
const { fullscreenCamera, setFullscreenCamera } = useUIStore();
|
||||
const cameras = useConfigStore((s) => s.enabledCameras());
|
||||
|
||||
const currentIdx = cameras.findIndex((c) => c.name === fullscreenCamera);
|
||||
const camera = currentIdx >= 0 ? cameras[currentIdx] : null;
|
||||
|
||||
const navigate = useCallback((dir: 1 | -1) => {
|
||||
if (cameras.length === 0) return;
|
||||
const next = (currentIdx + dir + cameras.length) % cameras.length;
|
||||
setFullscreenCamera(cameras[next].name);
|
||||
}, [cameras, currentIdx, setFullscreenCamera]);
|
||||
|
||||
const close = useCallback(() => setFullscreenCamera(null), [setFullscreenCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') navigate(1);
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') navigate(-1);
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [close, navigate]);
|
||||
|
||||
if (!camera) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-dark-primary flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="h-12 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-dark-hover rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-base font-semibold text-white">{camera.display_name}</h2>
|
||||
<button
|
||||
onClick={() => navigate(1)}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-dark-hover rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 ml-2">{currentIdx + 1} / {cameras.length}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={close}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-dark-hover rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<CameraPlayer
|
||||
key={camera.name}
|
||||
camera={camera}
|
||||
className="w-full h-full rounded-lg"
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/settings/AlertSettings.tsx
Normal file
84
frontend/src/components/settings/AlertSettings.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
|
||||
export function AlertSettings() {
|
||||
const config = useConfigStore((s) => s.config);
|
||||
const saveConfig = useConfigStore((s) => s.saveConfig);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enabled, setEnabled] = useState(config?.alerts.enabled ?? false);
|
||||
const [autoDismiss, setAutoDismiss] = useState(config?.alerts.auto_dismiss_seconds ?? 30);
|
||||
const [suppression, setSuppression] = useState(config?.alerts.suppression_seconds ?? 60);
|
||||
const [detectionTypes, setDetectionTypes] = useState(config?.alerts.detection_types.join(', ') ?? 'person');
|
||||
const [alertCameras, setAlertCameras] = useState(config?.alerts.cameras.join(', ') ?? '');
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await saveConfig({
|
||||
...config,
|
||||
alerts: {
|
||||
enabled,
|
||||
auto_dismiss_seconds: autoDismiss,
|
||||
suppression_seconds: suppression,
|
||||
detection_types: detectionTypes.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
cameras: alertCameras ? alertCameras.split(',').map((s) => s.trim()).filter(Boolean) : [],
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const inputClass = 'w-full bg-dark-tertiary border border-dark-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent';
|
||||
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`w-10 h-6 rounded-full transition-colors flex items-center ${enabled ? 'bg-accent justify-end' : 'bg-dark-hover justify-start'}`}
|
||||
>
|
||||
<div className="w-5 h-5 bg-white rounded-full mx-0.5" />
|
||||
</button>
|
||||
<span className="text-sm text-white">Enable person detection alerts</span>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Auto-dismiss (seconds)</label>
|
||||
<input className={inputClass} type="number" value={autoDismiss} onChange={(e) => setAutoDismiss(Number(e.target.value))} min={5} max={300} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Suppression (seconds)</label>
|
||||
<input className={inputClass} type="number" value={suppression} onChange={(e) => setSuppression(Number(e.target.value))} min={0} max={600} />
|
||||
<p className="text-xs text-gray-500 mt-1">Cooldown after dismissing an alert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Detection types</label>
|
||||
<input className={inputClass} value={detectionTypes} onChange={(e) => setDetectionTypes(e.target.value)} placeholder="person, car, dog" />
|
||||
<p className="text-xs text-gray-500 mt-1">Comma-separated Frigate detection labels</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Alert cameras (optional)</label>
|
||||
<input className={inputClass} value={alertCameras} onChange={(e) => setAlertCameras(e.target.value)} placeholder="Leave empty for all cameras" />
|
||||
<p className="text-xs text-gray-500 mt-1">Comma-separated camera names, or empty for all</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-accent hover:bg-accent/80 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Alerts'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/settings/CameraSettings.tsx
Normal file
135
frontend/src/components/settings/CameraSettings.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { discoverCameras } from '@/services/api';
|
||||
import type { CameraConfig } from '@/types/config';
|
||||
|
||||
export function CameraSettings() {
|
||||
const config = useConfigStore((s) => s.config);
|
||||
const saveConfig = useConfigStore((s) => s.saveConfig);
|
||||
const [cameras, setCameras] = useState<CameraConfig[]>(config?.cameras ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const handleToggle = (idx: number) => {
|
||||
const updated = [...cameras];
|
||||
updated[idx] = { ...updated[idx], enabled: !updated[idx].enabled };
|
||||
setCameras(updated);
|
||||
};
|
||||
|
||||
const handleNameChange = (idx: number, display_name: string) => {
|
||||
const updated = [...cameras];
|
||||
updated[idx] = { ...updated[idx], display_name };
|
||||
setCameras(updated);
|
||||
};
|
||||
|
||||
const handleMoveUp = (idx: number) => {
|
||||
if (idx === 0) return;
|
||||
const updated = [...cameras];
|
||||
[updated[idx - 1], updated[idx]] = [updated[idx], updated[idx - 1]];
|
||||
updated.forEach((c, i) => c.order = i);
|
||||
setCameras(updated);
|
||||
};
|
||||
|
||||
const handleMoveDown = (idx: number) => {
|
||||
if (idx >= cameras.length - 1) return;
|
||||
const updated = [...cameras];
|
||||
[updated[idx], updated[idx + 1]] = [updated[idx + 1], updated[idx]];
|
||||
updated.forEach((c, i) => c.order = i);
|
||||
setCameras(updated);
|
||||
};
|
||||
|
||||
const handleRemove = (idx: number) => {
|
||||
setCameras(cameras.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleDiscover = async () => {
|
||||
setDiscovering(true);
|
||||
const streams = await discoverCameras(config.go2rtc.url);
|
||||
const existing = new Set(cameras.map((c) => c.name));
|
||||
const newCams = streams
|
||||
.filter((s) => !existing.has(s))
|
||||
.map((name, i) => ({
|
||||
name,
|
||||
display_name: name.replace(/_/g, ' '),
|
||||
enabled: true,
|
||||
order: cameras.length + i,
|
||||
}));
|
||||
if (newCams.length > 0) {
|
||||
setCameras([...cameras, ...newCams]);
|
||||
}
|
||||
setDiscovering(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await saveConfig({ ...config, cameras });
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white">{cameras.length} Cameras</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering}
|
||||
className="px-3 py-1.5 bg-dark-tertiary border border-dark-border hover:border-accent text-sm text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
{discovering ? 'Discovering...' : 'Auto-Discover'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{cameras.map((cam, idx) => (
|
||||
<div
|
||||
key={cam.name}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg ${cam.enabled ? 'bg-dark-secondary' : 'bg-dark-secondary/50 opacity-60'}`}
|
||||
>
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => handleToggle(idx)}
|
||||
className={`w-8 h-5 rounded-full transition-colors flex items-center ${cam.enabled ? 'bg-accent justify-end' : 'bg-dark-hover justify-start'}`}
|
||||
>
|
||||
<div className="w-4 h-4 bg-white rounded-full mx-0.5" />
|
||||
</button>
|
||||
|
||||
{/* Stream name */}
|
||||
<span className="text-xs text-gray-500 font-mono w-32 truncate">{cam.name}</span>
|
||||
|
||||
{/* Display name */}
|
||||
<input
|
||||
className="flex-1 bg-dark-tertiary border border-dark-border rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-accent"
|
||||
value={cam.display_name}
|
||||
onChange={(e) => handleNameChange(idx, e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Reorder */}
|
||||
<button onClick={() => handleMoveUp(idx)} className="text-gray-500 hover:text-white p-1" title="Move up">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" /></svg>
|
||||
</button>
|
||||
<button onClick={() => handleMoveDown(idx)} className="text-gray-500 hover:text-white p-1" title="Move down">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
|
||||
{/* Remove */}
|
||||
<button onClick={() => handleRemove(idx)} className="text-gray-500 hover:text-status-error p-1" title="Remove">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-accent hover:bg-accent/80 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Cameras'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/settings/GeneralSettings.tsx
Normal file
108
frontend/src/components/settings/GeneralSettings.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
|
||||
export function GeneralSettings() {
|
||||
const config = useConfigStore((s) => s.config);
|
||||
const saveConfig = useConfigStore((s) => s.saveConfig);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [title, setTitle] = useState(config?.title ?? '');
|
||||
const [go2rtcUrl, setGo2rtcUrl] = useState(config?.go2rtc.url ?? '');
|
||||
const [frigateUrl, setFrigateUrl] = useState(config?.frigate.url ?? '');
|
||||
const [mqttHost, setMqttHost] = useState(config?.mqtt.host ?? '');
|
||||
const [mqttPort, setMqttPort] = useState(config?.mqtt.port ?? 1883);
|
||||
const [mqttUser, setMqttUser] = useState(config?.mqtt.username ?? '');
|
||||
const [mqttPass, setMqttPass] = useState(config?.mqtt.password ?? '');
|
||||
const [gridCols, setGridCols] = useState<string>(config?.grid.columns?.toString() ?? 'auto');
|
||||
const [gridGap, setGridGap] = useState(config?.grid.gap ?? 4);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await saveConfig({
|
||||
...config,
|
||||
title,
|
||||
go2rtc: { url: go2rtcUrl },
|
||||
frigate: { url: frigateUrl },
|
||||
mqtt: { ...config.mqtt, host: mqttHost, port: mqttPort, username: mqttUser, password: mqttPass },
|
||||
grid: { ...config.grid, columns: gridCols === 'auto' ? null : parseInt(gridCols), gap: gridGap },
|
||||
});
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const inputClass = 'w-full bg-dark-tertiary border border-dark-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent';
|
||||
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div>
|
||||
<label className={labelClass}>Title</label>
|
||||
<input className={inputClass} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dark-border pt-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Streaming</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className={labelClass}>go2rtc URL</label>
|
||||
<input className={inputClass} value={go2rtcUrl} onChange={(e) => setGo2rtcUrl(e.target.value)} placeholder="http://host:1985" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Frigate URL</label>
|
||||
<input className={inputClass} value={frigateUrl} onChange={(e) => setFrigateUrl(e.target.value)} placeholder="http://host:5000" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dark-border pt-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">MQTT</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Host</label>
|
||||
<input className={inputClass} value={mqttHost} onChange={(e) => setMqttHost(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Port</label>
|
||||
<input className={inputClass} type="number" value={mqttPort} onChange={(e) => setMqttPort(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Username</label>
|
||||
<input className={inputClass} value={mqttUser} onChange={(e) => setMqttUser(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Password</label>
|
||||
<input className={inputClass} type="password" value={mqttPass} onChange={(e) => setMqttPass(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dark-border pt-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Grid Layout</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Columns</label>
|
||||
<select className={inputClass} value={gridCols} onChange={(e) => setGridCols(e.target.value)}>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Gap (px)</label>
|
||||
<input className={inputClass} type="number" value={gridGap} onChange={(e) => setGridGap(Number(e.target.value))} min={0} max={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-accent hover:bg-accent/80 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/settings/SettingsPage.tsx
Normal file
39
frontend/src/components/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { GeneralSettings } from './GeneralSettings';
|
||||
import { CameraSettings } from './CameraSettings';
|
||||
import { AlertSettings } from './AlertSettings';
|
||||
|
||||
const TABS = ['General', 'Cameras', 'Alerts'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
export function SettingsPage() {
|
||||
const [tab, setTab] = useState<Tab>('General');
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-dark-border bg-dark-secondary px-4 shrink-0">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{tab === 'General' && <GeneralSettings />}
|
||||
{tab === 'Cameras' && <CameraSettings />}
|
||||
{tab === 'Alerts' && <AlertSettings />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/hooks/useAlerts.ts
Normal file
35
frontend/src/hooks/useAlerts.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AlertWebSocket } from '@/services/alerts';
|
||||
import { useAlertStore } from '@/stores/alertStore';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
|
||||
export function useAlerts() {
|
||||
const wsRef = useRef<AlertWebSocket | null>(null);
|
||||
const config = useConfigStore((s) => s.config);
|
||||
const { showAlert, isSuppressed, activeAlert } = useAlertStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.alerts.enabled) return;
|
||||
|
||||
const ws = new AlertWebSocket();
|
||||
wsRef.current = ws;
|
||||
|
||||
const unsub = ws.subscribe((event) => {
|
||||
// Don't show if there's already an active alert
|
||||
if (useAlertStore.getState().activeAlert) return;
|
||||
|
||||
// Check suppression
|
||||
if (isSuppressed(event.camera, config.alerts.suppression_seconds)) return;
|
||||
|
||||
showAlert(event);
|
||||
});
|
||||
|
||||
ws.connect();
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
ws.disconnect();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [config?.alerts.enabled]);
|
||||
}
|
||||
90
frontend/src/hooks/useStream.ts
Normal file
90
frontend/src/hooks/useStream.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Go2RTCWebRTC, Go2RTCMSE } from '@/services/go2rtc';
|
||||
|
||||
interface UseStreamOptions {
|
||||
streamName: string;
|
||||
go2rtcUrl: string;
|
||||
delayMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseStreamResult {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
isConnecting: boolean;
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true }: UseStreamOptions): UseStreamResult {
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
|
||||
const mseRef = useRef<Go2RTCMSE | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !streamName || !go2rtcUrl) return;
|
||||
|
||||
let mounted = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const connectWebRTC = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
|
||||
const webrtc = new Go2RTCWebRTC(streamName, go2rtcUrl);
|
||||
webrtcRef.current = webrtc;
|
||||
|
||||
await webrtc.connect((stream) => {
|
||||
if (mounted && videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
setIsConnecting(false);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.warn(`WebRTC failed for ${streamName}, trying MSE...`);
|
||||
await connectMSE();
|
||||
}
|
||||
};
|
||||
|
||||
const connectMSE = async () => {
|
||||
try {
|
||||
if (!mounted || !videoRef.current) return;
|
||||
|
||||
const mse = new Go2RTCMSE(streamName, go2rtcUrl);
|
||||
mseRef.current = mse;
|
||||
await mse.connect(videoRef.current);
|
||||
if (mounted) setIsConnecting(false);
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : 'Connection failed');
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (delayMs > 0) {
|
||||
timer = setTimeout(connectWebRTC, delayMs);
|
||||
} else {
|
||||
connectWebRTC();
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
webrtcRef.current?.disconnect();
|
||||
webrtcRef.current = null;
|
||||
mseRef.current?.disconnect();
|
||||
mseRef.current = null;
|
||||
};
|
||||
}, [streamName, go2rtcUrl, delayMs, enabled, retryCount]);
|
||||
|
||||
return { videoRef, isConnecting, error, retry };
|
||||
}
|
||||
16
frontend/src/index.css
Normal file
16
frontend/src/index.css
Normal file
@@ -0,0 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but allow scroll */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
70
frontend/src/services/alerts.ts
Normal file
70
frontend/src/services/alerts.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export interface AlertEvent {
|
||||
type: 'alert';
|
||||
camera: string;
|
||||
label: string;
|
||||
event_id: string;
|
||||
has_snapshot: boolean;
|
||||
has_clip: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type EventHandler = (event: AlertEvent) => void;
|
||||
|
||||
export class AlertWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers: Set<EventHandler> = new Set();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private url: string;
|
||||
|
||||
constructor() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this.url = `${proto}//${location.host}/api/ws/events`;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as AlertEvent;
|
||||
if (data.type === 'alert') {
|
||||
this.handlers.forEach((h) => h(data));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
subscribe(handler: EventHandler): () => void {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
}
|
||||
29
frontend/src/services/api.ts
Normal file
29
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AppConfig } from '@/types/config';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
export async function fetchConfig(): Promise<AppConfig> {
|
||||
const res = await fetch(`${BASE}/config`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
const res = await fetch(`${BASE}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to save config: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function discoverCameras(go2rtcUrl: string): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${go2rtcUrl}/api/streams`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Object.keys(data);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
137
frontend/src/services/go2rtc.ts
Normal file
137
frontend/src/services/go2rtc.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export class Go2RTCWebRTC {
|
||||
private pc: RTCPeerConnection | null = null;
|
||||
private mediaStream: MediaStream | null = null;
|
||||
private streamName: string;
|
||||
private go2rtcUrl: string;
|
||||
private onTrackCb: ((stream: MediaStream) => void) | null = null;
|
||||
|
||||
constructor(streamName: string, go2rtcUrl: string) {
|
||||
this.streamName = streamName;
|
||||
this.go2rtcUrl = go2rtcUrl;
|
||||
}
|
||||
|
||||
async connect(onTrack: (stream: MediaStream) => void): Promise<void> {
|
||||
this.onTrackCb = onTrack;
|
||||
|
||||
this.pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
});
|
||||
|
||||
this.pc.ontrack = (event) => {
|
||||
if (event.streams?.[0]) {
|
||||
this.mediaStream = event.streams[0];
|
||||
this.onTrackCb?.(this.mediaStream);
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.onicecandidate = () => {
|
||||
// go2rtc handles ICE internally via the initial SDP exchange
|
||||
};
|
||||
|
||||
this.pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
this.pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
|
||||
const offer = await this.pc.createOffer();
|
||||
await this.pc.setLocalDescription(offer);
|
||||
|
||||
const url = `${this.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/sdp' },
|
||||
body: offer.sdp,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`WebRTC offer failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const answerSdp = await res.text();
|
||||
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.mediaStream?.getTracks().forEach((t) => t.stop());
|
||||
this.mediaStream = null;
|
||||
this.pc?.close();
|
||||
this.pc = null;
|
||||
this.onTrackCb = null;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.pc?.connectionState === 'connected';
|
||||
}
|
||||
}
|
||||
|
||||
export class Go2RTCMSE {
|
||||
private mediaSource: MediaSource | null = null;
|
||||
private sourceBuffer: SourceBuffer | null = null;
|
||||
private ws: WebSocket | null = null;
|
||||
private streamName: string;
|
||||
private go2rtcUrl: string;
|
||||
private videoElement: HTMLVideoElement | null = null;
|
||||
private queue: ArrayBuffer[] = [];
|
||||
|
||||
constructor(streamName: string, go2rtcUrl: string) {
|
||||
this.streamName = streamName;
|
||||
this.go2rtcUrl = go2rtcUrl;
|
||||
}
|
||||
|
||||
async connect(videoElement: HTMLVideoElement): Promise<void> {
|
||||
this.videoElement = videoElement;
|
||||
this.mediaSource = new MediaSource();
|
||||
videoElement.src = URL.createObjectURL(this.mediaSource);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.mediaSource!.addEventListener('sourceopen', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
const wsUrl = `${this.go2rtcUrl.replace('http', 'ws')}/api/ws?src=${encodeURIComponent(this.streamName)}`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'mse' && msg.value) {
|
||||
this.initSourceBuffer(msg.value);
|
||||
}
|
||||
} else if (this.sourceBuffer) {
|
||||
if (this.sourceBuffer.updating) {
|
||||
this.queue.push(event.data);
|
||||
} else {
|
||||
this.sourceBuffer.appendBuffer(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private initSourceBuffer(codec: string): void {
|
||||
if (!this.mediaSource || this.sourceBuffer) return;
|
||||
try {
|
||||
this.sourceBuffer = this.mediaSource.addSourceBuffer(codec);
|
||||
this.sourceBuffer.mode = 'segments';
|
||||
this.sourceBuffer.addEventListener('updateend', () => {
|
||||
if (this.queue.length > 0 && this.sourceBuffer && !this.sourceBuffer.updating) {
|
||||
this.sourceBuffer.appendBuffer(this.queue.shift()!);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Failed to create source buffer for ${this.streamName}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
if (this.mediaSource?.readyState === 'open') {
|
||||
try { this.mediaSource.endOfStream(); } catch {}
|
||||
}
|
||||
if (this.videoElement) {
|
||||
this.videoElement.src = '';
|
||||
this.videoElement = null;
|
||||
}
|
||||
this.sourceBuffer = null;
|
||||
this.mediaSource = null;
|
||||
this.queue = [];
|
||||
}
|
||||
}
|
||||
38
frontend/src/stores/alertStore.ts
Normal file
38
frontend/src/stores/alertStore.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
import type { AlertEvent } from '@/services/alerts';
|
||||
|
||||
interface AlertState {
|
||||
activeAlert: AlertEvent | null;
|
||||
suppressions: Record<string, number>; // camera -> timestamp
|
||||
showAlert: (event: AlertEvent) => void;
|
||||
dismissAlert: () => void;
|
||||
isSuppressed: (camera: string, suppressionSeconds: number) => boolean;
|
||||
}
|
||||
|
||||
export const useAlertStore = create<AlertState>((set, get) => ({
|
||||
activeAlert: null,
|
||||
suppressions: {},
|
||||
|
||||
showAlert: (event) => {
|
||||
set({ activeAlert: event });
|
||||
},
|
||||
|
||||
dismissAlert: () => {
|
||||
const alert = get().activeAlert;
|
||||
if (alert) {
|
||||
set((state) => ({
|
||||
activeAlert: null,
|
||||
suppressions: {
|
||||
...state.suppressions,
|
||||
[alert.camera]: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
isSuppressed: (camera, suppressionSeconds) => {
|
||||
const ts = get().suppressions[camera];
|
||||
if (!ts) return false;
|
||||
return Date.now() - ts < suppressionSeconds * 1000;
|
||||
},
|
||||
}));
|
||||
55
frontend/src/stores/configStore.ts
Normal file
55
frontend/src/stores/configStore.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { create } from 'zustand';
|
||||
import type { AppConfig, CameraConfig } from '@/types/config';
|
||||
import { fetchConfig, saveConfig as apiSaveConfig } from '@/services/api';
|
||||
|
||||
interface ConfigState {
|
||||
config: AppConfig | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
loadConfig: () => Promise<void>;
|
||||
saveConfig: (config: AppConfig) => Promise<void>;
|
||||
enabledCameras: () => CameraConfig[];
|
||||
}
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
title: 'Camera Viewer',
|
||||
go2rtc: { url: 'http://192.168.1.241:1985' },
|
||||
frigate: { url: 'http://192.168.1.241:5000' },
|
||||
mqtt: { host: '', port: 1883, topic_prefix: 'frigate', username: '', password: '' },
|
||||
cameras: [],
|
||||
alerts: { enabled: false, auto_dismiss_seconds: 30, suppression_seconds: 60, cameras: [], detection_types: ['person'] },
|
||||
grid: { columns: null, aspect_ratio: '16:9', gap: 4 },
|
||||
};
|
||||
|
||||
export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
config: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
loadConfig: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const config = await fetchConfig();
|
||||
set({ config, loading: false });
|
||||
} catch (e) {
|
||||
set({ config: defaultConfig, loading: false, error: String(e) });
|
||||
}
|
||||
},
|
||||
|
||||
saveConfig: async (config: AppConfig) => {
|
||||
try {
|
||||
await apiSaveConfig(config);
|
||||
set({ config });
|
||||
} catch (e) {
|
||||
set({ error: String(e) });
|
||||
}
|
||||
},
|
||||
|
||||
enabledCameras: () => {
|
||||
const config = get().config;
|
||||
if (!config) return [];
|
||||
return config.cameras
|
||||
.filter((c) => c.enabled)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
},
|
||||
}));
|
||||
17
frontend/src/stores/uiStore.ts
Normal file
17
frontend/src/stores/uiStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type View = 'grid' | 'settings';
|
||||
|
||||
interface UIState {
|
||||
view: View;
|
||||
fullscreenCamera: string | null;
|
||||
setView: (view: View) => void;
|
||||
setFullscreenCamera: (name: string | null) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
view: 'grid',
|
||||
fullscreenCamera: null,
|
||||
setView: (view) => set({ view, fullscreenCamera: null }),
|
||||
setFullscreenCamera: (name) => set({ fullscreenCamera: name }),
|
||||
}));
|
||||
46
frontend/src/types/config.ts
Normal file
46
frontend/src/types/config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface Go2RTCConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FrigateConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MQTTConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
topic_prefix: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
name: string;
|
||||
display_name: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface AlertConfig {
|
||||
enabled: boolean;
|
||||
auto_dismiss_seconds: number;
|
||||
suppression_seconds: number;
|
||||
cameras: string[];
|
||||
detection_types: string[];
|
||||
}
|
||||
|
||||
export interface GridConfig {
|
||||
columns: number | null;
|
||||
aspect_ratio: string;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
title: string;
|
||||
go2rtc: Go2RTCConfig;
|
||||
frigate: FrigateConfig;
|
||||
mqtt: MQTTConfig;
|
||||
cameras: CameraConfig[];
|
||||
alerts: AlertConfig;
|
||||
grid: GridConfig;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user