feat: add per-port SCTE-35 inject button and SRT destination display

This commit is contained in:
Zac Gaetano 2026-04-14 10:05:13 -04:00
parent b3f496269c
commit ad863c1ed5

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { PortStatus } from '../types'; import { PortStatus } from '../types';
interface PortCardProps { interface PortCardProps {
@ -8,6 +8,7 @@ interface PortCardProps {
onStartRecording: (portIndex: number) => void; onStartRecording: (portIndex: number) => void;
onStopRecording: (portIndex: number) => void; onStopRecording: (portIndex: number) => void;
onOpenConfig: (portIndex: number) => void; onOpenConfig: (portIndex: number) => void;
onInjectSCTE35: (portIndex: number, eventId: number, durationSeconds: number, srtDestinationUrl?: string) => Promise<void>;
} }
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
@ -17,15 +18,33 @@ function formatUptime(seconds: number): string {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
} }
function truncateFilePath(path: string, maxLength = 38): string { function truncateFilePath(path: string, maxLength = 36): string {
if (!path) return '—'; if (!path) return '—';
return path.length <= maxLength ? path : '...' + path.slice(-(maxLength - 3)); return path.length <= maxLength ? path : '...' + path.slice(-(maxLength - 3));
} }
export function PortCard({ export function PortCard({
port, isSelected, onSelect, onStartRecording, onStopRecording, onOpenConfig, port, isSelected, onSelect, onStartRecording, onStopRecording, onOpenConfig, onInjectSCTE35,
}: PortCardProps) { }: PortCardProps) {
const isRec = port.is_recording; const isRec = port.is_recording;
const [scteInjecting, setScteInjecting] = useState<string | null>(null); // url or 'all'
const [scteEventId, setScteEventId] = useState(1001 + port.port_index * 100);
const [scteDuration, setScteDuration] = useState(30);
const [showSCTE, setShowSCTE] = useState(false);
const hasSRT = port.srt_destinations && port.srt_destinations.length > 0;
const handleSCTEInject = async (e: React.MouseEvent, destUrl?: string) => {
e.stopPropagation();
const key = destUrl ?? 'all';
setScteInjecting(key);
try {
await onInjectSCTE35(port.port_index, scteEventId, scteDuration, destUrl);
setScteEventId(prev => prev + 1);
} finally {
setScteInjecting(null);
}
};
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: 'var(--bg-card)', background: 'var(--bg-card)',
@ -52,13 +71,13 @@ export function PortCard({
animation: isRec ? 'recBarPulse 1s ease-in-out infinite' : 'none', animation: isRec ? 'recBarPulse 1s ease-in-out infinite' : 'none',
}} /> }} />
<div style={{ padding: 16 }}> <div style={{ padding: 14 }}>
{/* Header row */} {/* Header row */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 22, fontWeight: 700, letterSpacing: '0.04em', lineHeight: 1, color: 'var(--text-primary)' }}> <div style={{ fontFamily: 'var(--font-ui)', fontSize: 20, fontWeight: 700, letterSpacing: '0.04em', lineHeight: 1, color: 'var(--text-primary)' }}>
PORT {port.port_index} PORT {port.port_index}
<span style={{ fontSize: 12, fontWeight: 400, letterSpacing: '0.2em', color: 'var(--text-secondary)', marginLeft: 6 }}>SDI</span> <span style={{ fontSize: 11, fontWeight: 400, letterSpacing: '0.2em', color: 'var(--text-secondary)', marginLeft: 6 }}>SDI</span>
</div> </div>
<span style={{ <span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.15em', fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.15em',
@ -73,18 +92,18 @@ export function PortCard({
</div> </div>
{/* Metrics */} {/* Metrics */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 12px', marginBottom: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px', marginBottom: 10 }}>
{[ {[
{ label: 'FPS', value: port.fps.toFixed(2), highlight: true }, { label: 'FPS', value: port.fps.toFixed(2), highlight: true },
{ label: 'Bitrate', value: `${port.bitrate_mbps.toFixed(1)} Mbps`, highlight: false }, { label: 'Bitrate', value: `${port.bitrate_mbps.toFixed(1)} Mbps`, highlight: false },
{ label: 'Frames', value: port.frame_count.toLocaleString(), highlight: false }, { label: 'Frames', value: port.frame_count.toLocaleString(), highlight: false },
{ label: 'Uptime', value: formatUptime(port.uptime_seconds), highlight: false }, { label: 'Uptime', value: formatUptime(port.uptime_seconds), highlight: false },
].map(({ label, value, highlight }) => ( ].map(({ label, value, highlight }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 600, letterSpacing: '0.25em', textTransform: 'uppercase' as const, color: 'var(--text-muted)' }}> <span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 600, letterSpacing: '0.25em', textTransform: 'uppercase' as const, color: 'var(--text-muted)' }}>
{label} {label}
</span> </span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: highlight ? 'var(--accent-amber)' : 'var(--text-mono)', letterSpacing: '0.03em' }}> <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: highlight ? 'var(--accent-amber)' : 'var(--text-mono)', letterSpacing: '0.03em' }}>
{value} {value}
</span> </span>
</div> </div>
@ -96,15 +115,15 @@ export function PortCard({
fontFamily: 'var(--font-mono)', fontSize: 10, fontFamily: 'var(--font-mono)', fontSize: 10,
color: isRec ? 'rgba(255,120,120,0.8)' : 'var(--text-secondary)', color: isRec ? 'rgba(255,120,120,0.8)' : 'var(--text-secondary)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
marginBottom: 14, padding: '6px 8px', marginBottom: 10, padding: '5px 7px',
background: 'rgba(0,0,0,0.3)', borderRadius: 2, background: 'rgba(0,0,0,0.3)', borderRadius: 2,
borderLeft: `2px solid ${isRec ? 'var(--rec-red)' : 'var(--border)'}`, borderLeft: `2px solid ${isRec ? 'var(--rec-red)' : 'var(--border)'}`,
}}> }}>
{truncateFilePath(port.current_file)} {truncateFilePath(port.current_file)}
</div> </div>
{/* Codec */} {/* Codec badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 600, letterSpacing: '0.25em', textTransform: 'uppercase' as const, color: 'var(--text-muted)' }}>Codec</span> <span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 600, letterSpacing: '0.25em', textTransform: 'uppercase' as const, color: 'var(--text-muted)' }}>Codec</span>
<span style={{ <span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.1em', fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.1em',
@ -114,30 +133,148 @@ export function PortCard({
}}> }}>
{port.codec} {port.codec}
</span> </span>
{hasSRT && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.1em',
padding: '2px 7px', borderRadius: 2,
background: 'rgba(255,154,26,0.12)', color: 'var(--accent-amber)',
border: '1px solid rgba(255,154,26,0.3)',
}}>
SRT ×{port.srt_destinations.length}
</span>
)}
</div> </div>
{/* SRT destinations + SCTE-35 per destination */}
{hasSRT && (
<div style={{ marginBottom: 10 }}>
{/* Toggle SCTE panel */}
<button
onClick={e => { e.stopPropagation(); setShowSCTE(v => !v); }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: showSCTE ? 'rgba(255,154,26,0.1)' : 'rgba(0,0,0,0.2)',
border: `1px solid ${showSCTE ? 'rgba(255,154,26,0.4)' : 'var(--border)'}`,
borderRadius: 3, padding: '6px 10px', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 10, fontWeight: 700,
letterSpacing: '0.2em', textTransform: 'uppercase',
color: showSCTE ? 'var(--accent-amber)' : 'var(--text-muted)',
transition: 'all 0.15s',
}}
>
<span> SCTE-35 Inject</span>
<span style={{ fontSize: 9 }}>{showSCTE ? '▲' : '▼'}</span>
</button>
{showSCTE && (
<div
onClick={e => e.stopPropagation()}
style={{
marginTop: 6, background: 'rgba(0,0,0,0.25)',
border: '1px solid var(--border)', borderRadius: 3,
padding: 10, display: 'flex', flexDirection: 'column', gap: 8,
}}
>
{/* Duration/EventId mini inputs */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 700, letterSpacing: '0.25em', textTransform: 'uppercase', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Event ID</span>
<input
type="number" min={1} max={65535}
value={scteEventId}
onChange={e => setScteEventId(Number(e.target.value))}
style={{
width: '100%', background: 'rgba(0,0,0,0.4)',
border: '1px solid var(--border)', borderRadius: 2,
padding: '5px 8px', fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--text-primary)', outline: 'none',
}}
/>
</div>
<div>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 9, fontWeight: 700, letterSpacing: '0.25em', textTransform: 'uppercase', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Duration (s)</span>
<input
type="number" min={1} max={600}
value={scteDuration}
onChange={e => setScteDuration(Number(e.target.value))}
style={{
width: '100%', background: 'rgba(0,0,0,0.4)',
border: '1px solid var(--border)', borderRadius: 2,
padding: '5px 8px', fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--text-primary)', outline: 'none',
}}
/>
</div>
</div>
{/* Inject all button */}
<button
onClick={e => handleSCTEInject(e)}
disabled={scteInjecting !== null}
style={{
width: '100%', fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 700,
letterSpacing: '0.15em', textTransform: 'uppercase',
background: scteInjecting === 'all' ? 'rgba(0,230,118,0.1)' : 'rgba(255,154,26,0.12)',
border: `1px solid ${scteInjecting === 'all' ? 'rgba(0,230,118,0.35)' : 'rgba(255,154,26,0.4)'}`,
color: scteInjecting === 'all' ? 'var(--green-safe)' : 'var(--accent-amber)',
padding: '7px 10px', borderRadius: 3, cursor: scteInjecting ? 'default' : 'pointer',
transition: 'all 0.15s',
}}
>
{scteInjecting === 'all' ? '✓ Injected' : '⬡ Inject All SRT'}
</button>
{/* Per-destination buttons */}
{port.srt_destinations.map((url, i) => {
const label = url.length > 28 ? '...' + url.slice(-26) : url;
const isActive = scteInjecting === url;
return (
<button
key={i}
onClick={e => handleSCTEInject(e, url)}
disabled={scteInjecting !== null}
style={{
width: '100%', fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.04em',
background: isActive ? 'rgba(0,230,118,0.08)' : 'rgba(0,0,0,0.2)',
border: `1px solid ${isActive ? 'rgba(0,230,118,0.3)' : 'rgba(122,164,255,0.25)'}`,
color: isActive ? 'var(--green-safe)' : '#7aa4ff',
padding: '6px 10px', borderRadius: 3, cursor: scteInjecting ? 'default' : 'pointer',
textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
transition: 'all 0.15s',
}}
>
{isActive ? '✓' : '↳'} {label}
</button>
);
})}
</div>
)}
</div>
)}
{/* Actions */} {/* Actions */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
{isRec ? ( {isRec ? (
<button <button
onClick={e => { e.stopPropagation(); onStopRecording(port.port_index); }} onClick={e => { e.stopPropagation(); onStopRecording(port.port_index); }}
style={{ style={{
flex: 1, fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600, flex: 1, fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
letterSpacing: '0.1em', textTransform: 'uppercase' as const, letterSpacing: '0.1em', textTransform: 'uppercase' as const,
background: 'rgba(255,32,32,0.12)', color: 'var(--rec-red)', background: 'rgba(255,32,32,0.12)', color: 'var(--rec-red)',
border: '1px solid rgba(255,32,32,0.35)', borderRadius: 3, border: '1px solid rgba(255,32,32,0.35)', borderRadius: 3,
padding: '7px 14px', cursor: 'pointer', padding: '7px 10px', cursor: 'pointer',
}} }}
> Stop</button> > Stop</button>
) : ( ) : (
<button <button
onClick={e => { e.stopPropagation(); onStartRecording(port.port_index); }} onClick={e => { e.stopPropagation(); onStartRecording(port.port_index); }}
style={{ style={{
flex: 1, fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600, flex: 1, fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
letterSpacing: '0.1em', textTransform: 'uppercase' as const, letterSpacing: '0.1em', textTransform: 'uppercase' as const,
background: 'rgba(0,230,118,0.12)', color: 'var(--green-safe)', background: 'rgba(0,230,118,0.12)', color: 'var(--green-safe)',
border: '1px solid rgba(0,230,118,0.35)', borderRadius: 3, border: '1px solid rgba(0,230,118,0.35)', borderRadius: 3,
padding: '7px 14px', cursor: 'pointer', padding: '7px 10px', cursor: 'pointer',
}} }}
> Start</button> > Start</button>
)} )}