feat: add per-port SCTE-35 inject button and SRT destination display
This commit is contained in:
parent
b3f496269c
commit
ad863c1ed5
1 changed files with 158 additions and 21 deletions
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue