Initial scaffold: React+TS+Vite frontend, FastAPI backend, config system
This commit is contained in:
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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user