diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx
index 49f9d23..965d32c 100644
--- a/services/web-ui/public/screens-playout.jsx
+++ b/services/web-ui/public/screens-playout.jsx
@@ -24,6 +24,26 @@ async function poFetch(path, opts) {
return window.ZAMPP_API.fetch('/playout' + path, opts);
}
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function fmtDuration(secs) {
+ if (!secs || secs < 0) return '—';
+ const s = Math.floor(secs);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const ss = s % 60;
+ const mm = String(m).padStart(2, '0');
+ const ssStr = String(ss).padStart(2, '0');
+ return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`;
+}
+
+function itemEffectiveDuration(it) {
+ const total = (it.asset_duration_ms || 0) / 1000;
+ const inPt = it.in_point != null ? Number(it.in_point) : 0;
+ const outPt = it.out_point != null ? Number(it.out_point) : total;
+ return Math.max(0, outPt - inPt);
+}
+
// ── Output-config sub-form (varies by output type) ───────────────────────────
function OutputConfigFields({ type, config, onChange }) {
const set = (k, v) => onChange({ ...config, [k]: v });
@@ -175,29 +195,37 @@ function MediaBin({ projectId }) {
);
}
-const MEDIA_STATUS_BADGE = {
- ready: 'success', staging: 'warn', pending: 'neutral', error: 'error',
-};
+// ── Staging progress bar ──────────────────────────────────────────────────────
+function StagingBar({ status }) {
+ return (
+
e.preventDefault()} onDrop={onContainerDrop}>
-
Playlist
+
+ Playlist
+ {dropErr && {dropErr}}
+
{items.length === 0 && (
Drag clips here to build the playlist.
)}
- {items.map((it, index) => (
-
onItemDragStart(e, index)}
- onDragOver={onItemDragOver}
- onDrop={e => onItemDrop(e, index)}>
-
{index + 1}
-
{it.clip_name || it.asset_id}
-
- {it.media_status}
-
- {it.media_status === 'error' && (
-
- )}
-
+ {items.map((it, index) => {
+ const isActive = index === activeIndex;
+ const dur = itemEffectiveDuration(it);
+ return (
+
onItemDragStart(e, index)}
+ onDragOver={onItemDragOver}
+ onDrop={e => onItemDrop(e, index)}>
+
+ {isActive ? ▶ : index + 1}
+
+ {it.clip_name || it.asset_id}
+ {fmtDuration(dur)}
+
+ {it.media_status}
+
+ {it.media_status === 'error' && (
+
+ )}
+
+
+
+ );
+ })}
+ {items.length > 0 && (
+
+ {items.length} clip{items.length !== 1 ? 's' : ''}
+ {fmtDuration(totalSecs)} total
- ))}
+ )}
);
}
// ── Transport bar ────────────────────────────────────────────────────────────
-function Transport({ channel, playlistId, onStatus }) {
+function Transport({ channel, playlistId, items, onStatus }) {
const [busy, setBusy] = React.useState(false);
const act = async (fn) => { setBusy(true); try { await fn(); } catch (e) { alert(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 play = () => act(async () => {
const r = await poFetch('/channels/' + channel.id + '/play', {
method: 'POST', body: JSON.stringify({ playlist_id: playlistId }),
@@ -277,7 +334,9 @@ function Transport({ channel, playlistId, onStatus }) {
const live = channel.status === 'running';
return (
-
+
@@ -288,7 +347,37 @@ function Transport({ channel, playlistId, onStatus }) {
// ── Program monitor ──────────────────────────────────────────────────────────
function ProgramMonitor({ channel, engine }) {
- const onAir = channel.status === 'running';
+ const videoRef = React.useRef(null);
+ const hlsRef = React.useRef(null);
+ const onAir = channel.status === 'running';
+ const previewUrl = `/media/live/${channel.id}/index.m3u8`;
+
+ React.useEffect(() => {
+ const vid = videoRef.current;
+ if (!vid) return;
+
+ // Tear down any previous HLS instance before re-evaluating.
+ if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
+
+ if (!onAir) { vid.src = ''; return; }
+
+ if (window.Hls && window.Hls.isSupported()) {
+ const hls = new window.Hls({ liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 6 });
+ hlsRef.current = hls;
+ hls.loadSource(previewUrl);
+ hls.attachMedia(vid);
+ hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
+ } else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
+ // Native HLS (Safari).
+ vid.src = previewUrl;
+ vid.play().catch(() => {});
+ }
+
+ return () => {
+ if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
+ };
+ }, [onAir, channel.id]);
+
return (
@@ -296,14 +385,16 @@ function ProgramMonitor({ channel, engine }) {
{channel.output_type?.toUpperCase()} · {channel.video_format}
- {engine && engine.currentClip
- ?
{engine.currentClip}
- :
{onAir ? 'Idle — no clip playing' : 'Channel stopped'}
}
+
+ {!onAir && (
+
Channel stopped
+ )}
{engine && (
- clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0}
- {engine.loop ? ' · loop' : ''}
+ {engine.currentClip && {engine.currentClip}}
+ clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0}
+ {engine.loop && · loop}
)}
@@ -366,6 +457,9 @@ function ChannelDetail({ channel, onChannelChange }) {
setCh(updated); onChannelChange(updated);
};
+ // engine.currentIndex maps directly to the sorted item position.
+ const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
+
return (
@@ -386,10 +480,16 @@ function ChannelDetail({ channel, onChannelChange }) {
-
loadItems()} />
+ loadItems()} />
{playlistId && (
-
+
)}
);
diff --git a/services/web-ui/public/styles-playout.css b/services/web-ui/public/styles-playout.css
index c4ce6d3..3882822 100644
--- a/services/web-ui/public/styles-playout.css
+++ b/services/web-ui/public/styles-playout.css
@@ -50,12 +50,26 @@
.po-onair { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: 0.04em; }
.po-onair.live { color: var(--danger); }
.po-monitor-screen {
- flex: 1; min-height: 220px; background: #000;
+ position: relative; flex: 1; min-height: 220px; background: #000;
display: flex; align-items: center; justify-content: center;
- color: var(--text-2);
}
-.po-monitor-clip { font-family: var(--font-mono); font-size: 14px; color: var(--text-1); }
-.po-monitor-foot { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px; }
+.po-monitor-video {
+ width: 100%; height: 100%; object-fit: contain; display: block;
+}
+.po-monitor-overlay {
+ position: absolute; inset: 0;
+ display: flex; align-items: center; justify-content: center;
+ background: rgba(0,0,0,0.6); color: var(--text-2);
+ pointer-events: none;
+}
+.po-monitor-foot {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px;
+}
+.po-monitor-clip-name {
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ color: var(--text-1);
+}
/* Media bin */
.po-bin {
@@ -75,7 +89,7 @@
/* Transport */
.po-transport {
- display: flex; gap: 8px; flex-wrap: wrap;
+ display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
}
@@ -84,19 +98,63 @@
border-radius: 12px; overflow: hidden;
min-height: 120px;
}
-.po-playlist-empty { padding: 28px 12px; text-align: center; }
-.po-pl-item {
+.po-playlist-head {
display: flex; align-items: center; gap: 10px;
- padding: 9px 12px; border-bottom: 1px solid var(--border);
+ padding: 8px 12px; border-bottom: 1px solid var(--border);
+}
+.po-drop-err { font-size: 11px; color: var(--danger); }
+.po-playlist-empty { padding: 28px 12px; text-align: center; }
+
+.po-pl-item {
+ position: relative;
+ display: flex; align-items: center; gap: 10px;
+ padding: 9px 12px 13px; /* extra bottom padding for the staging bar */
+ border-bottom: 1px solid var(--border);
cursor: grab; user-select: none;
}
.po-pl-item:hover { background: var(--bg-3); }
.po-pl-item:active { cursor: grabbing; }
+.po-pl-item--active {
+ background: color-mix(in srgb, var(--danger) 8%, transparent);
+ border-left: 3px solid var(--danger);
+}
+.po-pl-item--active:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
+
.po-pl-index {
width: 22px; text-align: center; font-family: var(--font-mono);
- font-size: 12px; color: var(--text-3);
+ font-size: 12px; color: var(--text-3); flex-shrink: 0;
}
+.po-pl-onair { color: var(--danger); font-size: 11px; }
.po-pl-name { flex: 1; font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.po-pl-dur { font-size: 11px; color: var(--text-3); flex-shrink: 0; min-width: 40px; text-align: right; }
+.po-pl-badge { flex-shrink: 0; }
+
+/* Staging progress bar — sits flush at the bottom of each playlist item */
+.po-staging-bar {
+ position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
+}
+.po-staging-bar--pending { background: var(--text-3); opacity: 0.3; }
+.po-staging-bar--staging {
+ background: linear-gradient(90deg, transparent 0%, var(--warning) 50%, transparent 100%);
+ background-size: 200% 100%;
+ animation: po-staging-sweep 1.4s linear infinite;
+}
+.po-staging-bar--ready { background: var(--success); opacity: 0.8; }
+.po-staging-bar--error { background: var(--danger); }
+
+@keyframes po-staging-sweep {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* Playlist footer */
+.po-playlist-footer {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 8px 12px; border-top: 1px solid var(--border);
+ font-size: 11px; color: var(--text-3);
+ background: var(--bg-2);
+}
+.po-pl-total { color: var(--text-2); }
/* Small button variants reused */
.btn.xs { padding: 2px 8px; font-size: 11px; }