diff --git a/services/mam-api/src/db/migrations/033-playout-scte.sql b/services/mam-api/src/db/migrations/033-playout-scte.sql
new file mode 100644
index 0000000..9d3e56d
--- /dev/null
+++ b/services/mam-api/src/db/migrations/033-playout-scte.sql
@@ -0,0 +1,52 @@
+-- Migration 033 — SCTE-35 ad-break markers for playout.
+--
+-- Adds the missing SCTE-35 splice feature to the playout (MCR) subsystem. An
+-- operator can either schedule an ad break on a channel's timeline (relative to
+-- the active playlist position, or at a wall-clock time) or fire one immediately
+-- ("splice now"). Each break is recorded here and, when fired, also written to
+-- the append-only as-run log so it shows in the compliance record alongside the
+-- clips that aired.
+--
+-- type:
+-- splice_insert — a scheduled break (out → return), duration_s seconds long
+-- immediate — fire-now splice (operator pressed "Trigger ad break now")
+-- splice_out — open-ended avail out (provider break start)
+-- splice_in — return-to-network (provider break end)
+--
+-- status: pending → fired (when the engine acts on it) → done (when the break
+-- window has elapsed). cancelled is set if the operator removes a pending break.
+--
+-- The engine (services/playout) acts on a break by logging the cue, marking the
+-- as-run row, and — where the output path supports it — injecting a real
+-- SCTE-35 cue (see playout-manager.triggerScte for the injection point/TODO).
+
+CREATE TABLE IF NOT EXISTS playout_scte_breaks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
+ -- Position on the active playlist this break should fire after (0-based item
+ -- index). NULL for immediate/wall-clock breaks.
+ playlist_pos INTEGER,
+ -- Wall-clock fire time for scheduled breaks. NULL for immediate breaks.
+ scheduled_at TIMESTAMPTZ,
+ duration_s INTEGER NOT NULL DEFAULT 30,
+ -- SCTE-35 event id (the splice_event_id carried in the cue). Auto-assigned.
+ event_id INTEGER NOT NULL DEFAULT 1,
+ type TEXT NOT NULL DEFAULT 'splice_insert',
+ status TEXT NOT NULL DEFAULT 'pending',
+ fired_at TIMESTAMPTZ,
+ created_by UUID REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CHECK (type IN ('splice_insert','immediate','splice_out','splice_in')),
+ CHECK (status IN ('pending','fired','done','cancelled'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_playout_scte_channel ON playout_scte_breaks (channel_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_playout_scte_status ON playout_scte_breaks (status);
+
+-- As-run gains a 'scte' result so fired breaks land in the compliance log next to
+-- the clips. The original migration constrained result to played/skipped/error;
+-- widen it.
+ALTER TABLE playout_as_run DROP CONSTRAINT IF EXISTS playout_as_run_result_check;
+ALTER TABLE playout_as_run ADD CONSTRAINT playout_as_run_result_check
+ CHECK (result IN ('played','skipped','error','scte'));
diff --git a/services/mam-api/src/routes/playout.js b/services/mam-api/src/routes/playout.js
index f002c9b..ca66cc2 100644
--- a/services/mam-api/src/routes/playout.js
+++ b/services/mam-api/src/routes/playout.js
@@ -476,6 +476,117 @@ router.get('/channels/:id/asrun', async (req, res, next) => {
} catch (err) { next(err); }
});
+// ── SCTE-35 ad-break splices ───────────────────────────────────────────────
+// Schedule, trigger, and list SCTE-35 ad breaks on a channel. A break can be
+// scheduled (after a playlist position, or at a wall-clock time) or fired
+// immediately. Firing tells the sidecar to splice the live output, marks the
+// break 'fired', and stamps a row in the as-run compliance log.
+const SCTE_TYPES = new Set(['splice_insert', 'immediate', 'splice_out', 'splice_in']);
+
+// Fire a break row on the sidecar + record it. Shared by the immediate-trigger
+// route and the scheduler's due-break sweep. Best-effort: a sidecar failure
+// marks the break 'error' via error_message but never throws to the caller's
+// HTTP path beyond what's handled here.
+export async function fireScteBreak(channel, brk) {
+ const out = await callSidecar(channel, '/scte/trigger', 'POST', {
+ eventId: brk.event_id,
+ type: brk.type === 'immediate' ? 'splice_insert' : brk.type,
+ durationS: brk.duration_s,
+ });
+ await pool.query(
+ `UPDATE playout_scte_breaks SET status = 'fired', fired_at = NOW(), updated_at = NOW() WHERE id = $1`,
+ [brk.id]
+ );
+ // Stamp the compliance log. ended_at/duration are known up front for a
+ // fixed-duration break, so the row is written closed.
+ await pool.query(
+ `INSERT INTO playout_as_run
+ (channel_id, item_id, clip_name, started_at, ended_at, duration_s, result)
+ VALUES ($1, $2, $3, NOW(),
+ CASE WHEN $4 > 0 THEN NOW() + ($4 || ' seconds')::interval ELSE NULL END,
+ $4, 'scte')`,
+ [channel.id, brk.id, `SCTE-35 ${brk.type} (${brk.duration_s}s)`, brk.duration_s]
+ );
+ return out;
+}
+
+router.get('/channels/:id/scte', async (req, res, next) => {
+ try {
+ const { rows } = await pool.query(
+ `SELECT * FROM playout_scte_breaks WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 200`,
+ [req.channel.id]);
+ res.json(rows);
+ } catch (err) { next(err); }
+});
+
+// Schedule a break. Body: { type, duration_s, playlist_pos?, scheduled_at? }.
+// A pending break with a playlist_pos / scheduled_at is fired later by the
+// scheduler; one with neither is fired immediately for convenience.
+router.post('/channels/:id/scte', requireChannelEdit, async (req, res, next) => {
+ try {
+ const { type = 'splice_insert', duration_s = 30,
+ playlist_pos = null, scheduled_at = null } = req.body || {};
+ if (!SCTE_TYPES.has(type)) {
+ return res.status(400).json({ error: `type must be one of: ${[...SCTE_TYPES].join(', ')}` });
+ }
+ const dur = Math.max(0, parseInt(duration_s, 10) || 0);
+ // Auto-assign a monotonically increasing splice_event_id per channel.
+ const ev = await pool.query(
+ `SELECT COALESCE(MAX(event_id), 0) + 1 AS next FROM playout_scte_breaks WHERE channel_id = $1`,
+ [req.channel.id]);
+ const eventId = ev.rows[0].next;
+ const { rows } = await pool.query(
+ `INSERT INTO playout_scte_breaks
+ (channel_id, playlist_pos, scheduled_at, duration_s, event_id, type, created_by)
+ VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
+ [req.channel.id, playlist_pos, scheduled_at, dur, eventId, type, req.user?.id || null]);
+ res.status(201).json(rows[0]);
+ } catch (err) { next(err); }
+});
+
+// Fire an ad break immediately ("splice now"). Body: { type?, duration_s? }.
+// Creates the break row and triggers the splice on the live output in one shot.
+router.post('/channels/:id/scte/trigger', requireChannelEdit, async (req, res, next) => {
+ try {
+ if (req.channel.status !== 'running') {
+ return res.status(409).json({ error: 'Channel is not running' });
+ }
+ const { type = 'immediate', duration_s = 30 } = req.body || {};
+ if (!SCTE_TYPES.has(type)) {
+ return res.status(400).json({ error: `type must be one of: ${[...SCTE_TYPES].join(', ')}` });
+ }
+ const dur = Math.max(0, parseInt(duration_s, 10) || 0);
+ const ev = await pool.query(
+ `SELECT COALESCE(MAX(event_id), 0) + 1 AS next FROM playout_scte_breaks WHERE channel_id = $1`,
+ [req.channel.id]);
+ const eventId = ev.rows[0].next;
+ const { rows } = await pool.query(
+ `INSERT INTO playout_scte_breaks (channel_id, duration_s, event_id, type, status, created_by)
+ VALUES ($1,$2,$3,$4,'pending',$5) RETURNING *`,
+ [req.channel.id, dur, eventId, type, req.user?.id || null]);
+ try {
+ const out = await fireScteBreak(req.channel, rows[0]);
+ const updated = await pool.query('SELECT * FROM playout_scte_breaks WHERE id = $1', [rows[0].id]);
+ res.json({ break: updated.rows[0], engine: out });
+ } catch (err) {
+ await pool.query(`UPDATE playout_scte_breaks SET status = 'cancelled', updated_at = NOW() WHERE id = $1`,
+ [rows[0].id]).catch(() => {});
+ return res.status(502).json({ error: 'Sidecar unreachable: ' + err.message });
+ }
+ } catch (err) { next(err); }
+});
+
+router.delete('/channels/:id/scte/:scteId', requireChannelEdit, async (req, res, next) => {
+ try {
+ const { rows } = await pool.query(
+ `UPDATE playout_scte_breaks SET status = 'cancelled', updated_at = NOW()
+ WHERE id = $1 AND channel_id = $2 AND status = 'pending' RETURNING id`,
+ [req.params.scteId, req.channel.id]);
+ if (rows.length === 0) return res.status(404).json({ error: 'Pending break not found' });
+ res.json({ cancelled: true });
+ } catch (err) { next(err); }
+});
+
async function loadChannelForBody(req, res, next) {
const channelId = req.body.channel_id || req.query.channel_id;
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js
index 143297e..a929bc1 100644
--- a/services/mam-api/src/scheduler.js
+++ b/services/mam-api/src/scheduler.js
@@ -9,7 +9,7 @@
import pool from './db/pool.js';
import { syncToAmpp } from './routes/upload.js';
-import { restartChannel } from './routes/playout.js';
+import { restartChannel, fireScteBreak } from './routes/playout.js';
import { INTERNAL_TOKEN } from './middleware/auth.js';
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
@@ -289,6 +289,32 @@ async function playoutHealthTick(client) {
} catch (e) {
console.warn(`[scheduler] as-run write failed for ${ch.id}: ${e.message}`);
}
+
+ // SCTE-35: fire any pending scheduled breaks now due. Position-based
+ // breaks (playlist_pos) fire when the engine reaches that item; wall-clock
+ // breaks fire at scheduled_at. Failures mark the break cancelled so a bad
+ // break never wedges the sweep.
+ try {
+ const { rows: due } = await client.query(
+ `SELECT * FROM playout_scte_breaks
+ WHERE channel_id = $1 AND status = 'pending'
+ AND ( (scheduled_at IS NOT NULL AND scheduled_at <= NOW())
+ OR (playlist_pos IS NOT NULL AND playlist_pos <= $2) )
+ ORDER BY created_at ASC`,
+ [ch.id, (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1]
+ );
+ for (const brk of due) {
+ try { await fireScteBreak(ch, brk); }
+ catch (e2) {
+ console.warn(`[scheduler] scte fire failed for break ${brk.id}: ${e2.message}`);
+ await client.query(
+ `UPDATE playout_scte_breaks SET status = 'cancelled', updated_at = NOW() WHERE id = $1`,
+ [brk.id]).catch(() => {});
+ }
+ }
+ } catch (e) {
+ console.warn(`[scheduler] scte sweep failed for ${ch.id}: ${e.message}`);
+ }
} catch (err) {
// When last_heartbeat_at is NULL (channel just spawned), fall back to
// updated_at (set to NOW() by spawnChannelSidecar). This prevents a
diff --git a/services/playout/src/index.js b/services/playout/src/index.js
index accbd2d..646f18e 100644
--- a/services/playout/src/index.js
+++ b/services/playout/src/index.js
@@ -44,6 +44,20 @@ app.post('/transport/skip', async (req, res) => { try { res.json(await playout
app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } });
+// Fire an SCTE-35 ad-break splice on the live output. Body:
+// { eventId, type: 'splice_insert'|'immediate'|'splice_out'|'splice_in', durationS }
+// Returns the active-break descriptor (or the splice_in ack) so the mam-api can
+// stamp the as-run log.
+app.post('/scte/trigger', (req, res) => {
+ try {
+ const { eventId = 1, type = 'splice_insert', durationS = 30 } = req.body || {};
+ res.json(playoutManager.triggerScte({ eventId, type, durationS }));
+ } catch (err) {
+ console.error('[playout] /scte/trigger error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
app.get('/status', (req, res) => res.json(playoutManager.getStatus()));
// Auto-start: when the sidecar is spawned by mam-api with channel env, bring up
diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js
index abb3ba9..9c63512 100644
--- a/services/playout/src/playout-manager.js
+++ b/services/playout/src/playout-manager.js
@@ -83,8 +83,13 @@ export class PlayoutManager {
currentClip: null,
startedAt: null,
lastError: null,
+ // SCTE-35: the currently-active ad break, if any. Set by triggerScte and
+ // cleared by a timer when the break window elapses. Surfaced in getStatus
+ // so the UI can render an "in break" state + countdown.
+ scteActive: null, // { eventId, type, durationS, firedAt(iso), endsAt(iso) }
};
this._advanceTimer = null;
+ this._scteTimer = null;
this._hlsProc = null; // standalone ffmpeg re-mux child process
this._hlsRestartTimer = null;
}
@@ -305,6 +310,7 @@ export class PlayoutManager {
async stopChannel() {
this._clearAdvance();
+ this._clearScte();
this.state.running = false; // set first so the ffmpeg exit handler won't respawn
this._stopHlsRemux();
try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
@@ -430,6 +436,92 @@ export class PlayoutManager {
return this.getStatus();
}
+ // ── SCTE-35 ad-break splice ──────────────────────────────────────────────
+ // Act on an ad-break cue. The mam-api owns scheduling + persistence; the
+ // sidecar performs the actual splice on the live output and tracks the active
+ // break locally so /status can report a countdown.
+ //
+ // What this does today, end to end:
+ // 1. Records the break as the active break (UI reads it from /status for the
+ // "SCTE BREAK" on-air state + countdown). A timer clears it after
+ // durationS so the UI returns to normal automatically.
+ // 2. Emits an operator-visible log line at the splice point.
+ // 3. Returns the cue descriptor so the mam-api can stamp the as-run log.
+ //
+ // ── Real in-stream SCTE-35 injection (the injection point) ─────────────────
+ // True SCTE-35 requires inserting a splice_info_section into the OUTPUT
+ // transport stream on a dedicated SCTE-35 PID, time-aligned to the splice
+ // point (pts_time). CasparCG 2.3's FFMPEG consumer does NOT expose an SCTE-35
+ // muxer option, so we cannot ask CasparCG to carry the cue. The two viable
+ // production paths, neither of which the current single-process CasparCG
+ // output supports out of the box, are:
+ //
+ // (a) ffmpeg-based output: when the primary consumer is replaced by a
+ // Node-spawned ffmpeg (as the HLS preview re-mux already is), mux an
+ // SCTE-35 data stream. ffmpeg can pass through a -map'd scte35 PID, and
+ // for HLS can emit #EXT-X-CUE-OUT/#EXT-X-CUE-IN (or DATERANGE) tags. The
+ // hook would build the splice_insert binary section here and feed it to
+ // that ffmpeg via a data input / sidecar packetizer.
+ // (b) A downstream SCTE-35 inserter (e.g. an OTT packager / encoder that
+ // accepts cue triggers over its own API). The hook would POST the cue
+ // to that device's API at the splice instant.
+ //
+ // Until one of those output paths is wired, the splice is faithfully
+ // scheduled, triggered, countdown-tracked, and as-run-logged — but the cue is
+ // NOT yet embedded in the SRT/RTMP/SDI/NDI elementary stream. Replace the body
+ // of _injectScteCue below to enable real injection.
+ triggerScte({ eventId = 1, type = 'splice_insert', durationS = 30 } = {}) {
+ const firedAt = new Date();
+ const endsAt = new Date(firedAt.getTime() + (durationS > 0 ? durationS * 1000 : 0));
+
+ // Build + emit the cue on the output (TODO injection point — see above).
+ this._injectScteCue({ eventId, type, durationS });
+
+ // A splice_in / return-to-network ends any active break immediately.
+ if (type === 'splice_in') {
+ this._clearScte();
+ console.log(`[playout][scte] splice_in event=${eventId} — return to network`);
+ return { eventId, type, durationS: 0, firedAt: firedAt.toISOString(), endsAt: firedAt.toISOString() };
+ }
+
+ this.state.scteActive = {
+ eventId, type, durationS,
+ firedAt: firedAt.toISOString(),
+ endsAt: endsAt.toISOString(),
+ };
+ console.log(`[playout][scte] ${type} event=${eventId} duration=${durationS}s — splice OUT at ${firedAt.toISOString()}`);
+
+ // Auto-clear the active break when its window elapses (splice_out is
+ // open-ended, so it stays until an explicit splice_in).
+ if (this._scteTimer) { clearTimeout(this._scteTimer); this._scteTimer = null; }
+ if (durationS > 0 && type !== 'splice_out') {
+ this._scteTimer = setTimeout(() => {
+ this._scteTimer = null;
+ console.log(`[playout][scte] break event=${eventId} ended — return to network`);
+ this._clearScte();
+ }, durationS * 1000);
+ }
+ return this.state.scteActive;
+ }
+
+ // The SCTE-35 cue packetizer / injection hook. See the long comment on
+ // triggerScte for why this is a stub on the current CasparCG output path and
+ // what to put here to enable real in-stream injection.
+ _injectScteCue({ eventId, type, durationS }) {
+ // TODO(scte-injection): build the splice_info_section (splice_insert with
+ // splice_event_id=eventId, out_of_network_indicator per type,
+ // break_duration=durationS*90000 ticks) and emit it on the output's SCTE-35
+ // PID via an ffmpeg-based output, or POST it to a downstream inserter's API.
+ // No-op until the output path supports it; the scheduling/trigger/as-run
+ // path above is fully functional regardless.
+ return null;
+ }
+
+ _clearScte() {
+ if (this._scteTimer) { clearTimeout(this._scteTimer); this._scteTimer = null; }
+ this.state.scteActive = null;
+ }
+
_reportAsRunStart(item) {
// The mam-api owns the as-run table; the sidecar just logs locally. The API
// polls /status and writes as-run rows on clip change. Keeping the DB write
@@ -451,6 +543,7 @@ export class PlayoutManager {
loop: this.state.loop,
startedAt: this.state.startedAt,
lastError: this.state.lastError,
+ scteActive: this.state.scteActive || null,
};
}
}
diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx
index 01011a5..a5535fd 100644
--- a/services/web-ui/public/screens-playout.jsx
+++ b/services/web-ui/public/screens-playout.jsx
@@ -1,15 +1,25 @@
// screens-playout.jsx — Master Control (MCR) playout page.
//
-// Operator workflow (Phase A — playlist player):
-// 1. Create / pick a channel (output target: SRT / RTMP / NDI / DeckLink).
-// 2. Start the channel → spawns the CasparCG sidecar, brings up the output.
-// 3. Drag assets from the media bin into the playlist; reorder by dragging.
-// Each item stages from S3 to the CasparCG /media volume in the background.
-// 4. Hit PLAY → the engine walks the playlist gaplessly. PAUSE / SKIP / STOP
-// transport. As-run log records what aired.
+// Redesigned (timeline-centric): styled PGM monitor with audio meters +
+// timecode, transport bar, SCTE-35 break panel, now-playing + up-next, a
+// horizontal timeline, and a slide-in as-run drawer — all wired to the real
+// /api/v1/playout backend (no mock data).
//
-// Talks to /api/v1/playout via window.ZAMPP_API.fetch. Native HTML5 drag-drop,
-// no extra library. Components are plain globals (esbuild bundle:false).
+// API wiring summary:
+// - Channel CRUD: GET/POST/DELETE /playout/channels
+// - Channel lifecycle: POST /channels/:id/start|stop
+// - Engine status: GET /channels/:id/status (polls 4s; carries scteActive)
+// - HLS preview: GET /channels/:id/hls/index.m3u8 (hls.js + error recovery)
+// - Playlist CRUD: GET/POST /playlists, GET/POST/PUT /playlists/:id/items
+// - Transport: POST /channels/:id/play|pause|resume|skip|stop-playback
+// - As-run log: GET /channels/:id/asrun (polls 5s, drawer)
+// - Staging: POST /items/:id/stage (retry)
+// - SCTE-35: GET/POST /channels/:id/scte, POST /channels/:id/scte/trigger,
+// DELETE /channels/:id/scte/:id
+//
+// esbuild bundle:false + jsx:transform — no import statements.
+// Globals: React, Icon, window.ZAMPP_API, window.ZAMPP_DATA, window.Hls,
+// window.useConfirm/ConfirmModal.
const PO_OUTPUTS = [
{ value: 'srt', label: 'SRT' },
@@ -24,7 +34,7 @@ async function poFetch(path, opts) {
return window.ZAMPP_API.fetch('/playout' + path, opts);
}
-// ── Helpers ──────────────────────────────────────────────────────────────────
+// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDuration(secs) {
if (!secs || secs < 0) return '—';
@@ -37,6 +47,16 @@ function fmtDuration(secs) {
return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`;
}
+function fmtTimecode(secs) {
+ // HH:MM:SS:FF at 29.97 (rounded)
+ const s = Math.floor(secs);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const ss = s % 60;
+ const ff = Math.floor(((secs - s) * 29.97));
+ return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}:${String(ff).padStart(2,'0')}`;
+}
+
function itemEffectiveDuration(it) {
const total = (it.asset_duration_ms || 0) / 1000;
const inPt = it.in_point != null ? Number(it.in_point) : 0;
@@ -44,7 +64,34 @@ function itemEffectiveDuration(it) {
return Math.max(0, outPt - inPt);
}
-// ── Output-config sub-form (varies by output type) ───────────────────────────
+// ── Clock ─────────────────────────────────────────────────────────────────────
+function LiveClock() {
+ const [time, setTime] = React.useState(new Date());
+ React.useEffect(() => {
+ const id = setInterval(() => setTime(new Date()), 500);
+ return () => clearInterval(id);
+ }, []);
+ const pad = n => String(n).padStart(2, '0');
+ return (
+ {pad(time.getHours())}:{pad(time.getMinutes())}:{pad(time.getSeconds())}
+ );
+}
+
+// ── Elapsed timer ─────────────────────────────────────────────────────────────
+function useElapsed(startedAt) {
+ const [elapsed, setElapsed] = React.useState(0);
+ React.useEffect(() => {
+ if (!startedAt) { setElapsed(0); return; }
+ const base = new Date(startedAt).getTime();
+ const tick = () => setElapsed(Math.max(0, (Date.now() - base) / 1000));
+ tick();
+ const id = setInterval(tick, 100);
+ return () => clearInterval(id);
+ }, [startedAt]);
+ return elapsed;
+}
+
+// ── Output-config sub-form ────────────────────────────────────────────────────
function OutputConfigFields({ type, config, onChange }) {
const set = (k, v) => onChange({ ...config, [k]: v });
if (type === 'decklink') {
@@ -65,7 +112,6 @@ function OutputConfigFields({ type, config, onChange }) {
);
}
- // srt / rtmp
return (
@@ -92,7 +138,7 @@ function OutputConfigFields({ type, config, onChange }) {
);
}
-// ── Channel create modal ─────────────────────────────────────────────────────
+// ── Channel create modal ──────────────────────────────────────────────────────
function ChannelCreate({ onClose, onCreated }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [name, setName] = React.useState('');
@@ -162,7 +208,7 @@ function ChannelCreate({ onClose, onCreated }) {
);
}
-// ── Media bin: assets draggable into the playlist ────────────────────────────
+// ── Media bin ─────────────────────────────────────────────────────────────────
function MediaBin({ projectId }) {
const ASSETS = (window.ZAMPP_DATA?.ASSETS || []).filter(a =>
!projectId || a.project_id === projectId);
@@ -195,14 +241,14 @@ function MediaBin({ projectId }) {
);
}
-// ── Staging progress bar ──────────────────────────────────────────────────────
+// ── Staging bar ───────────────────────────────────────────────────────────────
function StagingBar({ status }) {
return (
);
}
-// ── Playlist: ordered, drag-drop reorder, drop-target for bin assets ─────────
+// ── Playlist ──────────────────────────────────────────────────────────────────
function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
const [dragIndex, setDragIndex] = React.useState(null);
const [dropErr, setDropErr] = React.useState(null);
@@ -215,7 +261,7 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
const onItemDrop = async (e, index) => {
e.preventDefault();
- e.stopPropagation(); // prevent bubble to onContainerDrop
+ e.stopPropagation();
setDropErr(null);
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (assetRaw) {
@@ -228,7 +274,6 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
return;
}
- // Reorder within the playlist.
if (dragIndex === null || dragIndex === index) return;
const order = items.map(i => i.id);
const [moved] = order.splice(dragIndex, 1);
@@ -271,6 +316,9 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {