From ad863c1ed58d248052d889ac5864dbbb2a220732 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 14 Apr 2026 10:05:13 -0400 Subject: [PATCH] feat: add per-port SCTE-35 inject button and SRT destination display --- frontend/src/components/PortCard.tsx | 179 +++++++++++++++++++++++---- 1 file changed, 158 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/PortCard.tsx b/frontend/src/components/PortCard.tsx index f945f17..0d34c05 100644 --- a/frontend/src/components/PortCard.tsx +++ b/frontend/src/components/PortCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { PortStatus } from '../types'; interface PortCardProps { @@ -8,6 +8,7 @@ interface PortCardProps { onStartRecording: (portIndex: number) => void; onStopRecording: (portIndex: number) => void; onOpenConfig: (portIndex: number) => void; + onInjectSCTE35: (portIndex: number, eventId: number, durationSeconds: number, srtDestinationUrl?: string) => Promise; } 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')}`; } -function truncateFilePath(path: string, maxLength = 38): string { +function truncateFilePath(path: string, maxLength = 36): string { if (!path) return '—'; return path.length <= maxLength ? path : '...' + path.slice(-(maxLength - 3)); } export function PortCard({ - port, isSelected, onSelect, onStartRecording, onStopRecording, onOpenConfig, + port, isSelected, onSelect, onStartRecording, onStopRecording, onOpenConfig, onInjectSCTE35, }: PortCardProps) { const isRec = port.is_recording; + const [scteInjecting, setScteInjecting] = useState(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 = { background: 'var(--bg-card)', @@ -52,13 +71,13 @@ export function PortCard({ animation: isRec ? 'recBarPulse 1s ease-in-out infinite' : 'none', }} /> -
+
{/* Header row */} -
-
+
+
PORT {port.port_index} - SDI + SDI
{/* Metrics */} -
+
{[ - { label: 'FPS', value: port.fps.toFixed(2), highlight: true }, - { label: 'Bitrate', value: `${port.bitrate_mbps.toFixed(1)} Mbps`, highlight: false }, - { label: 'Frames', value: port.frame_count.toLocaleString(), highlight: false }, - { label: 'Uptime', value: formatUptime(port.uptime_seconds), highlight: false }, + { label: 'FPS', value: port.fps.toFixed(2), highlight: true }, + { label: 'Bitrate', value: `${port.bitrate_mbps.toFixed(1)} Mbps`, highlight: false }, + { label: 'Frames', value: port.frame_count.toLocaleString(), highlight: false }, + { label: 'Uptime', value: formatUptime(port.uptime_seconds), highlight: false }, ].map(({ label, value, highlight }) => ( -
+
{label} - + {value}
@@ -96,15 +115,15 @@ export function PortCard({ fontFamily: 'var(--font-mono)', fontSize: 10, color: isRec ? 'rgba(255,120,120,0.8)' : 'var(--text-secondary)', 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, borderLeft: `2px solid ${isRec ? 'var(--rec-red)' : 'var(--border)'}`, }}> {truncateFilePath(port.current_file)}
- {/* Codec */} -
+ {/* Codec badge */} +
Codec {port.codec} + {hasSRT && ( + + SRT ×{port.srt_destinations.length} + + )}
+ {/* SRT destinations + SCTE-35 per destination */} + {hasSRT && ( +
+ {/* Toggle SCTE panel */} + + + {showSCTE && ( +
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 */} +
+
+ Event ID + 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', + }} + /> +
+
+ Duration (s) + 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', + }} + /> +
+
+ + {/* Inject all button */} + + + {/* Per-destination buttons */} + {port.srt_destinations.map((url, i) => { + const label = url.length > 28 ? '...' + url.slice(-26) : url; + const isActive = scteInjecting === url; + return ( + + ); + })} +
+ )} +
+ )} + {/* Actions */}
{isRec ? ( ) : ( )}