deltacast-sdi-recorder/frontend/src/App.tsx

259 lines
10 KiB
TypeScript
Raw Normal View History

2026-04-14 09:21:15 -04:00
import React, { useState, useEffect } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { useRecorder } from './hooks/useRecorder';
import { PortCard } from './components/PortCard';
import { VideoPreview } from './components/VideoPreview';
import { ConfigPanel } from './components/ConfigPanel';
import { SCTE35Trigger } from './components/SCTE35Trigger';
import { PortStatus, RecorderConfig } from './types';
export default function App() {
const [ports, setPorts] = useState<PortStatus[]>([]);
const [selectedPort, setSelectedPort] = useState<number | null>(null);
const [configPort, setConfigPort] = useState<number | null>(null);
const [configs, setConfigs] = useState<Record<number, RecorderConfig>>({});
const [clock, setClock] = useState('');
2026-04-14 09:21:15 -04:00
const wsUrl = `ws://${window.location.host}/ws`;
const { isConnected, lastMessage } = useWebSocket(wsUrl);
const { startRecording, stopRecording, injectSCTE35 } = useRecorder();
// Clock
useEffect(() => {
const tick = () => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
setClock(`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`);
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// WebSocket port updates
2026-04-14 09:21:15 -04:00
useEffect(() => {
if (lastMessage?.type === 'port_status' && lastMessage.ports) {
setPorts(lastMessage.ports);
}
}, [lastMessage]);
// Initial fetch
2026-04-14 09:21:15 -04:00
useEffect(() => {
fetch('/api/ports')
.then(r => r.json())
.then(setPorts)
.catch(console.error);
}, []);
const handleStartRecording = async (portIndex: number) => {
const config = configs[portIndex] || defaultConfig(portIndex);
await startRecording(portIndex, config);
};
const handleSaveConfig = (config: RecorderConfig) => {
setConfigs(prev => ({ ...prev, [config.port_index]: config }));
};
const handleInjectSCTE35 = async (eventId: number, durationSeconds: number) => {
await injectSCTE35(eventId, durationSeconds);
};
const handleSelectPort = (portIndex: number) => {
setSelectedPort(prev => (prev === portIndex ? null : portIndex));
};
const selectedPortData = ports.find(p => p.port_index === selectedPort) ?? null;
2026-04-14 09:21:15 -04:00
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-deepest)', color: 'var(--text-primary)' }}>
{/* ── HEADER ── */}
<header style={{
background: 'var(--bg-dark)',
borderBottom: '1px solid var(--border)',
padding: '0 28px',
height: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'sticky',
top: 0,
zIndex: 100,
boxShadow: '0 2px 20px rgba(0,0,0,0.6)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<img
src="/dragon-logo.png"
alt="Dragon Encode"
style={{
width: 36, height: 36, objectFit: 'contain',
filter: 'drop-shadow(0 0 6px rgba(26,58,255,0.5))',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1 }}>
<span style={{
fontFamily: 'var(--font-ui)', fontWeight: 900, fontSize: 18,
letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--text-primary)',
}}>Dragon Encode</span>
<span style={{
fontFamily: 'var(--font-ui)', fontWeight: 400, fontSize: 11,
letterSpacing: '0.3em', textTransform: 'uppercase', color: 'var(--dragon-blue)',
marginTop: 2,
}}>SDI Recorder Suite</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: isConnected ? 'var(--green-safe)' : 'var(--rec-red)',
boxShadow: isConnected ? '0 0 0 3px rgba(0,230,118,0.15)' : '0 0 0 3px rgba(255,32,32,0.15)',
transition: 'all 0.3s',
}} />
<span>{isConnected ? 'LIVE' : 'DISCONNECTED'}</span>
2026-04-14 09:21:15 -04:00
</div>
<div style={{ width: 1, height: 28, background: 'var(--border)' }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text-mono)', letterSpacing: '0.05em' }}>
{clock}
</span>
2026-04-14 09:21:15 -04:00
</div>
</header>
<main style={{ padding: 28, maxWidth: 1440, margin: '0 auto' }}>
{/* ── SECTION LABEL ── */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<span style={{
fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
letterSpacing: '0.35em', textTransform: 'uppercase', color: 'var(--text-muted)',
}}>SDI Inputs</span>
<div style={{ flex: 1, height: 1, background: 'linear-gradient(to right, var(--border), transparent)' }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-muted)' }}>
{ports.length} PORT{ports.length !== 1 ? 'S' : ''}
</span>
</div>
{/* ── PORT CARDS ── */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 14,
marginBottom: 32,
}} className="ports-grid">
{ports.length === 0 ? (
<div style={{
gridColumn: '1 / -1', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: '60px 20px',
color: 'var(--text-muted)', gap: 12,
}}>
<span style={{ fontSize: 48, opacity: 0.4 }}></span>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600, letterSpacing: '0.2em', textTransform: 'uppercase' }}>
No Ports Available
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
Waiting for backend connection...
</span>
2026-04-14 09:21:15 -04:00
</div>
) : (
ports.map(port => (
<PortCard
key={port.port_index}
port={port}
isSelected={selectedPort === port.port_index}
onSelect={handleSelectPort}
onStartRecording={handleStartRecording}
onStopRecording={stopRecording}
onOpenConfig={setConfigPort}
/>
))
2026-04-14 09:21:15 -04:00
)}
</div>
{/* ── DETAIL PANEL ── */}
2026-04-14 09:21:15 -04:00
{selectedPort !== null && (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 20,
marginBottom: 32,
animation: 'panelEnter 0.25s ease both',
}}>
{/* Preview */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{
padding: '12px 16px', background: 'var(--bg-panel)',
borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
HLS Preview
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--dragon-blue)', letterSpacing: '0.08em' }}>
PORT {selectedPort}
</span>
</div>
<div style={{ padding: 16 }}>
<VideoPreview portIndex={selectedPort} />
</div>
2026-04-14 09:21:15 -04:00
</div>
{/* SCTE-35 */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{
padding: '12px 16px', background: 'var(--bg-panel)',
borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>
Ad Break Injection
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--accent-amber)', letterSpacing: '0.08em' }}>
SCTE-35 / 104
</span>
</div>
<div style={{ padding: 16 }}>
<SCTE35Trigger onInject={handleInjectSCTE35} />
</div>
2026-04-14 09:21:15 -04:00
</div>
</div>
)}
2026-04-14 09:21:15 -04:00
</main>
{/* ── CONFIG MODAL ── */}
2026-04-14 09:21:15 -04:00
{configPort !== null && (
<ConfigPanel
portIndex={configPort}
currentConfig={configs[configPort] ?? null}
2026-04-14 09:21:15 -04:00
onSave={handleSaveConfig}
onClose={() => setConfigPort(null)}
/>
)}
<style>{`
@keyframes panelEnter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.ports-grid { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 1100px) { .ports-grid { grid-template-columns: repeat(2, 1fr) !important; } }
@media (max-width: 600px) { .ports-grid { grid-template-columns: 1fr !important; } }
`}</style>
2026-04-14 09:21:15 -04:00
</div>
);
}
function defaultConfig(portIndex: number): RecorderConfig {
return {
port_index: portIndex,
codec: 'PRORES',
bitrate: '185M',
quality_profile: 'hq',
recording_path: `/recordings/port_${portIndex}_{timestamp}.mxf`,
srt_enabled: false,
srt_destination: '',
preview_enabled: true,
};
}