);
}
// ── Transport bar ─────────────────────────────────────────────────────────────
function Transport({ channel, playlistId, items, onStatus, onError }) {
const [busy, setBusy] = React.useState(false);
const act = async (fn) => {
setBusy(true);
try { await fn(); } catch (e) { onError && onError(e.message); }
finally { setBusy(false); }
};
const notReady = items.filter(i => i.media_status !== 'ready').length;
const canPlay = channel.status === 'running' && !busy && !!playlistId && notReady === 0;
const live = channel.status === 'running';
const play = () => act(async () => {
const r = await poFetch('/channels/' + channel.id + '/play', {
method: 'POST', body: JSON.stringify({ playlist_id: playlistId }),
});
onStatus && onStatus(r);
});
const pause = () => act(() => poFetch('/channels/' + channel.id + '/pause', { method: 'POST' }));
const resume = () => act(() => poFetch('/channels/' + channel.id + '/resume', { method: 'POST' }));
const skip = () => act(() => poFetch('/channels/' + channel.id + '/skip', { method: 'POST' }));
const stopPb = () => act(() => poFetch('/channels/' + channel.id + '/stop-playback', { method: 'POST' }));
return (
);
}
// ── SCTE-35 panel ─────────────────────────────────────────────────────────────
// Wired to the real backend:
// POST /channels/:id/scte/trigger — splice now (immediate ad break)
// POST /channels/:id/scte — schedule a break (at a playlist pos)
// GET /channels/:id/scte — recent breaks
// The active break (with countdown) comes from engine.scteActive on /status.
function Scte35Panel({ channel, engine, breaks, onReload, onError }) {
const [busy, setBusy] = React.useState(false);
const live = channel.status === 'running';
const scte = engine && engine.scteActive;
const triggerNow = (durationS, type) => async () => {
setBusy(true);
try {
await poFetch('/channels/' + channel.id + '/scte/trigger', {
method: 'POST', body: JSON.stringify({ type: type || 'immediate', duration_s: durationS }),
});
onReload && onReload();
} catch (e) { onError && onError('SCTE-35: ' + e.message); }
finally { setBusy(false); }
};
const scheduleAfterCurrent = (durationS) => async () => {
setBusy(true);
try {
const pos = (engine && engine.currentIndex >= 0) ? engine.currentIndex : 0;
await poFetch('/channels/' + channel.id + '/scte', {
method: 'POST',
body: JSON.stringify({ type: 'splice_insert', duration_s: durationS, playlist_pos: pos }),
});
onReload && onReload();
} catch (e) { onError && onError('SCTE-35: ' + e.message); }
finally { setBusy(false); }
};
const lastFired = (breaks || []).find(b => b.fired_at) || null;
const pending = (breaks || []).filter(b => b.status === 'pending');
const breakRemain = scte && scte.endsAt
? Math.max(0, Math.ceil((new Date(scte.endsAt).getTime() - Date.now()) / 1000))
: 0;
return (
SCTE-35 Break
{scte && (
In break · {scte.type} · {breakRemain > 0 ? breakRemain + 's left' : 'awaiting return'}
)}
Trigger now
Schedule after current clip
{lastFired && (
Last: {lastFired.type} {lastFired.duration_s > 0 ? lastFired.duration_s + 's' : ''} (event {lastFired.event_id})
)}
);
}
// ── Now Playing card ──────────────────────────────────────────────────────────
function NowPlayingCard({ engine, elapsed, items }) {
if (!engine || engine.currentIndex < 0) {
return (
Now Playing
Nothing playing
);
}
const currentItem = items[engine.currentIndex] || null;
const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0;
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
<<<<<<< HEAD
=======
>>>>>>> main
const nextItem = items[engine.currentIndex + 1] || null;
return (
Now Playing
{engine.currentIndex + 1} / {engine.playlistLength || items.length}
{engine.currentClip || '—'}
{playoutFmtDur(elapsed)}
-{playoutFmtDur(timeRemaining)}
{nextItem && (
Up next
{nextItem.clip_name || nextItem.asset_id}
)}
);
}
// ── Timeline ──────────────────────────────────────────────────────────────────
function Timeline({ items, activeIndex, elapsed, breaks }) {
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
if (items.length === 0) {
return (
Timeline
Add clips to the playlist to see the timeline.
);
}
let playheadPct = 0;
if (activeIndex >= 0 && totalSecs > 0) {
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
}
// Pending position-based breaks → markers at the end of their playlist_pos clip.
const breakMarkers = [];
if (totalSecs > 0) {
for (const b of (breaks || [])) {
if (b.status !== 'pending' || b.playlist_pos == null) continue;
const pos = Math.min(b.playlist_pos, items.length - 1);
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
}
}
const COLORS = ['#3b82f6','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ec4899','#6366f1','#14b8a6'];
return (
Timeline
{playoutFmtDur(totalSecs)} total
{activeIndex >= 0 && (
)}
{breakMarkers.map(m => (
))}
{items.map((it, i) => {
const dur = itemEffectiveDuration(it);
const pct = totalSecs > 0 ? (dur / totalSecs) * 100 : 0;
const isActive = i === activeIndex;
const color = COLORS[i % COLORS.length];
return (
{it.clip_name || it.asset_id}
{playoutFmtDur(dur)}
{it.media_status === 'staging' && (
)}
{it.media_status === 'error' && (
)}
);
})}
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
{playoutFmtDur((totalSecs * i) / 4)}
))}
);
}
let playheadPct = 0;
if (activeIndex >= 0 && totalSecs > 0) {
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
}
// Pending position-based breaks → markers at the end of their playlist_pos clip.
const breakMarkers = [];
if (totalSecs > 0) {
for (const b of (breaks || [])) {
if (b.status !== 'pending' || b.playlist_pos == null) continue;
const pos = Math.min(b.playlist_pos, items.length - 1);
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
}
}
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
if (items.length === 0) {
return (
{/* ON AIR / SCTE BREAK badge */}
{onAir && scte && (
SCTE BREAK{breakRemain > 0 ? ' ' + Math.ceil(breakRemain) + 's' : ''}
)}
{onAir && !scte &&
ON AIR
}
{!onAir && (
Channel stopped
)}
{onAir && (
{playoutFmtTC(elapsed)}
)}
Add clips to the playlist to see the timeline.
);
}
<<<<<<< HEAD
// Compute offset of active clip for the playhead
=======
>>>>>>> main
let playheadPct = 0;
if (activeIndex >= 0 && totalSecs > 0) {
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
}
<<<<<<< HEAD
=======
// Pending position-based breaks → markers at the end of their playlist_pos clip.
const breakMarkers = [];
if (totalSecs > 0) {
for (const b of (breaks || [])) {
if (b.status !== 'pending' || b.playlist_pos == null) continue;
const pos = Math.min(b.playlist_pos, items.length - 1);
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
}
}
>>>>>>> main
const COLORS = ['#3b82f6','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ec4899','#6366f1','#14b8a6'];
return (
Timeline
<<<<<<< HEAD
{fmtDuration(totalSecs)} total
{/* Playhead */}
{activeIndex >= 0 && (
)}
=======
{playoutFmtDur(totalSecs)} total
{activeIndex >= 0 && (
)}
{breakMarkers.map(m => (
))}
>>>>>>> main
{items.map((it, i) => {
const dur = itemEffectiveDuration(it);
const pct = totalSecs > 0 ? (dur / totalSecs) * 100 : 0;
const isActive = i === activeIndex;
const color = COLORS[i % COLORS.length];
return (
{it.clip_name || it.asset_id}
{fmtDuration(dur)}
=======
title={`${it.clip_name || it.asset_id} · ${playoutFmtDur(dur)}`}>
{it.clip_name || it.asset_id}
{playoutFmtDur(dur)}
>>>>>>> main
{it.media_status === 'staging' && (
)}
{it.media_status === 'error' && (
)}
);
})}
<<<<<<< HEAD
{/* Time ruler (rough marks) */}
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
{fmtDuration((totalSecs * i) / 4)}
=======
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
{playoutFmtDur((totalSecs * i) / 4)}
>>>>>>> main
))}
);
}
// ── As-run drawer ─────────────────────────────────────────────────────────────
function AsRunDrawer({ channel, refreshKey, open, onClose }) {
const [rows, setRows] = React.useState([]);
React.useEffect(() => {
if (!open) return;
let alive = true;
let t;
const poll = async () => {
try {
const r = await poFetch('/channels/' + channel.id + '/asrun');
if (alive) setRows(Array.isArray(r) ? r : []);
} catch (_) {}
t = setTimeout(poll, 5000);
};
poll();
return () => { alive = false; clearTimeout(t); };
}, [channel.id, refreshKey, open]);
const fmtTime = (ts) => {
if (!ts) return '—';
const d = new Date(ts);
return isNaN(d) ? '—' : d.toLocaleTimeString();
};
return (
{open && }
As-Run Log
{rows.length} entries
{rows.length === 0
?
No as-run entries yet.
: (
| Time | Clip | Duration | Result |
{rows.slice(0, 200).map((r) => (
| {fmtTime(r.started_at)} |
{r.clip_name || r.item_id || '—'} |
{r.duration_s != null
? playoutFmtDur(Number(r.duration_s))
: (r.ended_at ? '—' : 'on air')}
|
{r.result || 'played'}
|
))}
)}
);
}
// ── Channel detail ────────────────────────────────────────────────────────────
function ChannelDetail({ channel, onChannelChange }) {
const [playlists, setPlaylists] = React.useState([]);
const [playlistId, setPlaylistId] = React.useState(null);
const [items, setItems] = React.useState([]);
const [engine, setEngine] = React.useState(null);
const [ch, setCh] = React.useState(channel);
const [asRunOpen, setAsRunOpen] = React.useState(false);
const [binOpen, setBinOpen] = React.useState(false);
const [breaks, setBreaks] = React.useState([]);
const [actionErr, setActionErr] = React.useState(null);
const [confirm, confirmModal] = window.useConfirm();
React.useEffect(() => { setCh(channel); }, [channel.id]);
const loadPlaylists = React.useCallback(async () => {
const pls = await poFetch('/playlists?channel_id=' + channel.id);
setPlaylists(pls);
if (pls.length && !playlistId) setPlaylistId(pls[0].id);
if (!pls.length) {
const created = await poFetch('/playlists', {
method: 'POST', body: JSON.stringify({ channel_id: channel.id, name: 'Main' }),
});
setPlaylists([created]); setPlaylistId(created.id);
}
}, [channel.id]);
const loadItems = React.useCallback(async () => {
if (!playlistId) return;
const its = await poFetch('/playlists/' + playlistId + '/items');
setItems(its);
}, [playlistId]);
const loadBreaks = React.useCallback(async () => {
try { setBreaks(await poFetch('/channels/' + channel.id + '/scte')); }
catch (_) { /* table may be empty / migration pending */ }
}, [channel.id]);
React.useEffect(() => { loadPlaylists(); loadBreaks(); }, [channel.id]);
React.useEffect(() => { loadItems(); }, [playlistId]);
// Poll engine status + item staging + SCTE breaks.
React.useEffect(() => {
let t;
const poll = async () => {
try {
const s = await poFetch('/channels/' + channel.id + '/status');
setEngine(s.engine || null);
} catch (_) {}
try { await loadItems(); } catch (_) {}
try { await loadBreaks(); } catch (_) {}
t = setTimeout(poll, 4000);
};
poll();
return () => clearTimeout(t);
}, [channel.id, playlistId]);
const startChannel = async () => {
try {
const updated = await poFetch('/channels/' + channel.id + '/start', { method: 'POST' });
setCh(updated); onChannelChange(updated);
} catch (e) { setActionErr(e.message); }
};
const stopChannel = async () => {
try {
const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' });
setCh(updated); onChannelChange(updated);
} catch (e) { setActionErr(e.message); }
};
const deleteChannel = async () => {
if (!(await confirm({
title: 'Delete channel?',
message: 'Delete channel "' + ch.name + '"? This cannot be undone.',
confirmLabel: 'Delete', danger: true,
}))) return;
try {
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
onChannelChange({ ...ch, _deleted: true });
} catch (e) { setActionErr(e.message); }
};
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
<<<<<<< HEAD
const onAir = ch.status === 'running';
return (
{/* ── Top rail: monitor + right panel ── */}
{/* PGM monitor + transport */}
=======
return (
{confirmModal}
{/* ── Top rail: monitor + right panel ── */}
>>>>>>> main
{/* Right rail */}
=======
onError={setActionErr}
/>
>>>>>>> main
{/* Channel controls */}
Channel
{ch.status === 'running'
?
: }
{ch.status !== 'running' && (
)}
{ch.output_type?.toUpperCase()} · {ch.video_format}
{ch.restart_count > 0 && (
↺{ch.restart_count}
)}
{ch.error_message &&
{ch.error_message}
}
<<<<<<< HEAD
{/* Now playing */}
{/* SCTE-35 */}
{/* Quick actions */}
<<<<<<< HEAD
{/* Media bin (collapsible, below top rail) */}
{binOpen && (
)}
=======
{binOpen &&
}
>>>>>>> main
{/* Playlist */}
{playlistId && (
)}
<<<<<<< HEAD
{/* Timeline */}
{/* As-run drawer */}
=======
>>>>>>> main
setAsRunOpen(false)}
/>
);
}
// ── Top-level page ────────────────────────────────────────────────────────────
function Playout() {
const [channels, setChannels] = React.useState(null);
const [selectedId, setSelectedId] = React.useState(null);
const [showCreate, setShowCreate] = React.useState(false);
const [err, setErr] = React.useState(null);
const load = React.useCallback(async () => {
try {
const list = await poFetch('/channels');
setChannels(list);
if (list.length && !selectedId) setSelectedId(list[0].id);
} catch (e) { setErr(e.message); setChannels([]); }
}, [selectedId]);
React.useEffect(() => { load(); }, []);
const selected = (channels || []).find(c => c.id === selectedId) || null;
const onChannelChange = (updated) => {
if (updated._deleted) {
setChannels(cs => {
const next = (cs || []).filter(c => c.id !== updated.id);
setSelectedId(next.length ? next[0].id : null);
return next;
});
return;
}
setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c));
};
return (
{/* ── Page header with channel tabs + clock ── */}
Master Control
{(channels || []).map(c => (
))}
<<<<<<< HEAD
=======
⚠ Playout is in testing — not for production use.
>>>>>>> main
{err &&
{err}
}
{channels === null &&
Loading channels…
}
{channels !== null && channels.length === 0 && (
No playout channels yet.
)}
{selected && (
)}
{showCreate && (
setShowCreate(false)}
onCreated={(ch) => {
setShowCreate(false);
setChannels(cs => [...(cs || []), ch]);
setSelectedId(ch.id);
}}
/>
)}
);
}
window.Playout = Playout;