feat: redesign App.tsx with broadcast control room UI
This commit is contained in:
parent
6658bffdbd
commit
e2e569d12e
1 changed files with 174 additions and 38 deletions
|
|
@ -12,19 +12,32 @@ export default function App() {
|
||||||
const [selectedPort, setSelectedPort] = useState<number | null>(null);
|
const [selectedPort, setSelectedPort] = useState<number | null>(null);
|
||||||
const [configPort, setConfigPort] = useState<number | null>(null);
|
const [configPort, setConfigPort] = useState<number | null>(null);
|
||||||
const [configs, setConfigs] = useState<Record<number, RecorderConfig>>({});
|
const [configs, setConfigs] = useState<Record<number, RecorderConfig>>({});
|
||||||
|
const [clock, setClock] = useState('');
|
||||||
|
|
||||||
const wsUrl = `ws://${window.location.host}/ws`;
|
const wsUrl = `ws://${window.location.host}/ws`;
|
||||||
const { isConnected, lastMessage } = useWebSocket(wsUrl);
|
const { isConnected, lastMessage } = useWebSocket(wsUrl);
|
||||||
const { startRecording, stopRecording, injectSCTE35 } = useRecorder();
|
const { startRecording, stopRecording, injectSCTE35 } = useRecorder();
|
||||||
|
|
||||||
// Update ports from WebSocket messages
|
// 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
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastMessage?.type === 'port_status' && lastMessage.ports) {
|
if (lastMessage?.type === 'port_status' && lastMessage.ports) {
|
||||||
setPorts(lastMessage.ports);
|
setPorts(lastMessage.ports);
|
||||||
}
|
}
|
||||||
}, [lastMessage]);
|
}, [lastMessage]);
|
||||||
|
|
||||||
// Initial fetch of port statuses
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/ports')
|
fetch('/api/ports')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
|
|
@ -39,71 +52,194 @@ export default function App() {
|
||||||
|
|
||||||
const handleSaveConfig = (config: RecorderConfig) => {
|
const handleSaveConfig = (config: RecorderConfig) => {
|
||||||
setConfigs(prev => ({ ...prev, [config.port_index]: config }));
|
setConfigs(prev => ({ ...prev, [config.port_index]: config }));
|
||||||
// If not recording, optionally auto-start? No — just save
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInjectSCTE35 = async (eventId: number, durationSeconds: number) => {
|
const handleInjectSCTE35 = async (eventId: number, durationSeconds: number) => {
|
||||||
await injectSCTE35(eventId, durationSeconds);
|
await injectSCTE35(eventId, durationSeconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectPort = (portIndex: number) => {
|
||||||
|
setSelectedPort(prev => (prev === portIndex ? null : portIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedPortData = ports.find(p => p.port_index === selectedPort) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<div style={{ minHeight: '100vh', background: 'var(--bg-deepest)', color: 'var(--text-primary)' }}>
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-gray-800 border-b border-gray-700 px-6 py-4">
|
{/* ── HEADER ── */}
|
||||||
<div className="flex items-center justify-between">
|
<header style={{
|
||||||
<h1 className="text-xl font-bold text-blue-400">Deltacast SDI Recorder</h1>
|
background: 'var(--bg-dark)',
|
||||||
<div className="flex items-center gap-2">
|
borderBottom: '1px solid var(--border)',
|
||||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
padding: '0 28px',
|
||||||
<span className="text-sm text-gray-400">{isConnected ? 'Connected' : 'Disconnected'}</span>
|
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>
|
</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="p-6">
|
<main style={{ padding: 28, maxWidth: 1440, margin: '0 auto' }}>
|
||||||
{/* Port Cards Grid - responsive: 1 col mobile, 2 tablet, 4 desktop */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
{/* ── SECTION LABEL ── */}
|
||||||
{ports.map(port => (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
<PortCard
|
<span style={{
|
||||||
key={port.port_index}
|
fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
|
||||||
port={port}
|
letterSpacing: '0.35em', textTransform: 'uppercase', color: 'var(--text-muted)',
|
||||||
isSelected={selectedPort === port.port_index}
|
}}>SDI Inputs</span>
|
||||||
onSelect={setSelectedPort}
|
<div style={{ flex: 1, height: 1, background: 'linear-gradient(to right, var(--border), transparent)' }} />
|
||||||
onStartRecording={handleStartRecording}
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
onStopRecording={stopRecording}
|
{ports.length} PORT{ports.length !== 1 ? 'S' : ''}
|
||||||
onOpenConfig={setConfigPort}
|
</span>
|
||||||
/>
|
</div>
|
||||||
))}
|
|
||||||
{ports.length === 0 && (
|
{/* ── PORT CARDS ── */}
|
||||||
<div className="col-span-full text-center text-gray-500 py-12">
|
<div style={{
|
||||||
No ports available. Check backend connection.
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
ports.map(port => (
|
||||||
|
<PortCard
|
||||||
|
key={port.port_index}
|
||||||
|
port={port}
|
||||||
|
isSelected={selectedPort === port.port_index}
|
||||||
|
onSelect={handleSelectPort}
|
||||||
|
onStartRecording={handleStartRecording}
|
||||||
|
onStopRecording={stopRecording}
|
||||||
|
onOpenConfig={setConfigPort}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Port Detail View */}
|
{/* ── DETAIL PANEL ── */}
|
||||||
{selectedPort !== null && (
|
{selectedPort !== null && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div style={{
|
||||||
<div>
|
display: 'grid',
|
||||||
<h2 className="text-lg font-semibold mb-3">Port {selectedPort} Preview</h2>
|
gridTemplateColumns: '1fr 1fr',
|
||||||
<VideoPreview portIndex={selectedPort} />
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold mb-3">Ad Break Control</h2>
|
{/* SCTE-35 */}
|
||||||
<SCTE35Trigger onInject={handleInjectSCTE35} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Config Panel Modal */}
|
{/* ── CONFIG MODAL ── */}
|
||||||
{configPort !== null && (
|
{configPort !== null && (
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
portIndex={configPort}
|
portIndex={configPort}
|
||||||
currentConfig={configs[configPort] || null}
|
currentConfig={configs[configPort] ?? null}
|
||||||
onSave={handleSaveConfig}
|
onSave={handleSaveConfig}
|
||||||
onClose={() => setConfigPort(null)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue