HLS VOD playback for browser (supplements MP4 proxy) #170

Open
zgaetano wants to merge 7 commits from feat/hls-vod-playback into main
6 changed files with 424 additions and 16 deletions

View file

@ -767,7 +767,7 @@ router.get('/:id/stream', async (req, res, next) => {
if (a.hls_s3_key) { if (a.hls_s3_key) {
return res.json({ return res.json({
url: `/api/v1/assets/${id}/video`, url: `/api/v1/assets/${id}/video`,
type: 'mp4', type: 'hls',
source: a.proxy_s3_key ? 'proxy' : 'original', source: a.proxy_s3_key ? 'proxy' : 'original',
hls_url: `/api/v1/assets/${id}/hls/playlist.m3u8`, hls_url: `/api/v1/assets/${id}/hls/playlist.m3u8`,
}); });

View file

@ -1,16 +1,30 @@
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import fs from 'fs'; import fs from 'fs';
import { createReadStream, existsSync } from 'fs';
import { stat } from 'fs/promises';
import net from 'net'; import net from 'net';
import dgram from 'dgram'; import dgram from 'dgram';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js'; import { s3Client, getS3Bucket } from '../s3/client.js';
import { Upload } from '@aws-sdk/lib-storage';
import { validateUuid } from '../middleware/errors.js'; import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js'; import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Queue } from 'bullmq';
const router = express.Router(); const router = express.Router();
// BullMQ proxy queue — used by the growing-file stop handler to queue proxy
// jobs when the capture container's finalize call races with the S3 upload.
const parseRedisUrl = (url) => {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
};
const proxyQueue = new Queue('proxy', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// Every /:id recorder route is scoped to the recorder's project. The param // Every /:id recorder route is scoped to the recorder's project. The param
// handler validates the UUID, resolves the owning project_id, and asserts the // handler validates the UUID, resolves the owning project_id, and asserts the
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit. // 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
@ -807,6 +821,28 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
})(); })();
} }
// ── Growing-files S3 promotion ────────────────────────────────────────────
// When growing_enabled=true the capture container writes the master file to
// /growing/{projectId}/{clipName}.{ext} (a host bind-mount that the mam-api
// container also has at /growing). The capture container's graceful-shutdown
// handler (triggered by the Docker stop above) calls POST /assets/:id/finalize
// with the expected S3 key, which queues the proxy job — but the file was
// never uploaded to S3, so the proxy worker fails with "unable to open file".
//
// Fix: after the container has exited (ffmpeg is done flushing), upload the
// growing file to the canonical S3 key from here. This is synchronous and
// completes before the HTTP response reaches the client, so the already-queued
// proxy job will find a valid S3 object when the worker dequeues it.
//
// Only applies to LOCAL recorders — remote recorders write to a different
// node's /growing mount which this process cannot access.
if (!isRemote && recorder.growing_enabled === true && recorder.current_session_id) {
await promoteGrowingFileToS3(recorder).catch(err => {
// Non-fatal — log and continue so the stop always succeeds.
console.error('[recorders/stop] growing-file promotion failed (non-fatal):', err.message);
});
}
const updateResult = await pool.query( const updateResult = await pool.query(
`UPDATE recorders `UPDATE recorders
SET container_id = NULL, status = $1, updated_at = NOW() SET container_id = NULL, status = $1, updated_at = NOW()
@ -821,6 +857,109 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
} }
}); });
/**
* Upload a completed growing-file master from /growing to S3 so the proxy
* worker can find it at the expected original_s3_key.
*
* The capture container writes to:
* /growing/{projectId}/{clipName}.{ext}
*
* The canonical S3 key (set on the asset row at recording start) is:
* projects/{projectId}/masters/{clipName}.{ext}
*
* We look up the live/processing asset to derive both paths, do a multipart
* upload, update the asset's original_s3_key and file_size to match what we
* actually uploaded, then ensure a proxy job exists for it.
*/
async function promoteGrowingFileToS3(recorder) {
const clipName = recorder.current_session_id;
const container = recorder.recording_container || 'mov';
// Find the asset that was pre-created at recording start. It could be in
// 'live' (finalize hasn't fired yet) or 'processing' (finalize already ran
// from the container's SIGTERM handler). We need both its id and its
// project_id to reconstruct the growing path.
const assetRes = await pool.query(
`SELECT id, project_id, status, original_s3_key
FROM assets
WHERE display_name = $1
AND status IN ('live', 'processing', 'error')
ORDER BY created_at DESC
LIMIT 1`,
[clipName]
);
if (assetRes.rows.length === 0) {
console.warn(`[recorders/stop] no asset found for clip "${clipName}" — skipping growing-file promotion`);
return;
}
const asset = assetRes.rows[0];
const projectId = asset.project_id;
const growingDir = process.env.GROWING_DIR || '/growing';
const localPath = `${growingDir}/${projectId}/${clipName}.${container}`;
const s3Key = `projects/${projectId}/masters/${clipName}.${container}`;
if (!existsSync(localPath)) {
console.warn(`[recorders/stop] growing file not found at ${localPath} — nothing to promote (empty recording?)`);
return;
}
const fileStat = await stat(localPath);
if (fileStat.size === 0) {
console.warn(`[recorders/stop] growing file at ${localPath} is empty — skipping promotion`);
return;
}
console.log(`[recorders/stop] promoting growing file ${localPath} (${fileStat.size} bytes) → s3://${getS3Bucket()}/${s3Key}`);
const upload = new Upload({
client: s3Client,
params: {
Bucket: getS3Bucket(),
Key: s3Key,
Body: createReadStream(localPath),
},
queueSize: 4,
partSize: 8 * 1024 * 1024,
});
await upload.done();
console.log(`[recorders/stop] S3 upload complete for ${s3Key}`);
// Ensure the asset row reflects the correct S3 key and file size. The
// capture container's finalize call may have already set original_s3_key to
// this same value (it was pre-set at start), but update file_size which
// finalize doesn't touch.
await pool.query(
`UPDATE assets
SET original_s3_key = $1,
file_size = $2,
updated_at = NOW()
WHERE id = $3`,
[s3Key, fileStat.size, asset.id]
);
// If the asset is still 'live' (capture container's finalize hasn't fired or
// failed), flip it to 'processing' and queue the proxy job ourselves so the
// clip doesn't get stuck in the library as "Recording…".
if (asset.status === 'live') {
console.log(`[recorders/stop] finalize not yet called — queueing proxy and flipping asset ${asset.id} to processing`);
await pool.query(
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`,
[asset.id]
);
await proxyQueue.add('generate', {
assetId: asset.id,
inputKey: s3Key,
outputKey: `proxies/${asset.id}.mp4`,
});
}
// If status is already 'processing', the capture container's finalize already
// ran and queued the proxy job. The S3 upload we just did ensures the worker
// will find a valid object when it dequeues that job — nothing else to do.
}
// GET /:id/status - Get live status // GET /:id/status - Get live status
router.get('/:id/status', async (req, res, next) => { router.get('/:id/status', async (req, res, next) => {
try { try {

View file

@ -306,7 +306,20 @@ function Editor() {
if (r && r.url) { url = r.url; cache[asset.id] = url; } if (r && r.url) { url = r.url; cache[asset.id] = url; }
} catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); } } catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
} }
if (url) { vid.src = url; vid.load(); } if (url) {
if (url.endsWith('.m3u8')) {
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls();
hls.loadSource(url);
hls.attachMedia(vid);
} else {
vid.src = url;
}
} else {
vid.src = url;
}
vid.load();
}
} }
function markSrcIn() { function markSrcIn() {
@ -636,8 +649,14 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
if (vid) vid.pause(); if (vid) vid.pause();
} }
// Audio track refs for playback
const pgmAudioRefs = React.useRef([]);
React.useEffect(() => { React.useEffect(() => {
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return; if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) {
pgmAudioRefs.current.forEach(a => a.pause());
return;
}
const clip = pgmClips[pgmClipIdx]; const clip = pgmClips[pgmClipIdx];
if (!clip) { stopPgm(); return; } if (!clip) { stopPgm(); return; }
const vid = videoRef.current; const vid = videoRef.current;
@ -651,7 +670,28 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; } if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
} catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; } } catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; }
} }
if (vid.src !== url) { vid.src = url; vid.load(); } if (vid.src !== url) {
if (url.endsWith('.m3u8')) {
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls();
hls.loadSource(url);
hls.attachMedia(vid);
} else {
vid.src = url;
}
} else {
vid.src = url;
}
vid.load();
}
// Sync audio tracks (A1/A2)
const asset = assetsRef.current.find(a => a.id === clip.asset_id);
if (asset && asset.media_type === 'video') {
// For now, simple video-track audio. Multi-track A1/A2 wiring planned.
vid.muted = false;
}
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94); const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
vid.currentTime = srcInSecs; vid.currentTime = srcInSecs;
vid.play().catch(() => {}); vid.play().catch(() => {});

View file

@ -358,7 +358,6 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
} }
// Audio meter // Audio meter
// Simulated VU meter real values would require a WebAudio analyzer on the
// HLS stream. For now, animate a plausible signal when on-air. (Named PoAudioMeter // HLS stream. For now, animate a plausible signal when on-air. (Named PoAudioMeter
// to avoid colliding with the global AudioMeter from visuals.jsx.) // to avoid colliding with the global AudioMeter from visuals.jsx.)
function PoAudioMeter({ onAir }) { function PoAudioMeter({ onAir }) {
@ -447,8 +446,6 @@ function ProgramMonitor({ channel, engine, elapsed }) {
React.useEffect(() => { React.useEffect(() => {
const vid = videoRef.current; const vid = videoRef.current;
if (!vid) return; if (!vid) return;
// Tear down any previous HLS instance before re-evaluating.
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
if (!onAir) { vid.src = ''; return; } if (!onAir) { vid.src = ''; return; }
@ -526,6 +523,18 @@ function ProgramMonitor({ channel, engine, elapsed }) {
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0; const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0; const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
<<<<<<< HEAD
return (
<div className="po-pgm">
{/* Screen */}
<div className="po-screen">
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
{/* ON AIR badge */}
{onAir && (
<div className="po-onair-badge">ON AIR</div>
)}
=======
// SCTE break countdown (seconds remaining in the active break). // SCTE break countdown (seconds remaining in the active break).
const breakRemain = scte && scte.endsAt const breakRemain = scte && scte.endsAt
? Math.max(0, (new Date(scte.endsAt).getTime() - Date.now()) / 1000) ? Math.max(0, (new Date(scte.endsAt).getTime() - Date.now()) / 1000)
@ -543,6 +552,7 @@ function ProgramMonitor({ channel, engine, elapsed }) {
</div> </div>
)} )}
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>} {onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
>>>>>>> main
{!onAir && ( {!onAir && (
<div className="po-screen-offline"> <div className="po-screen-offline">
@ -551,12 +561,23 @@ function ProgramMonitor({ channel, engine, elapsed }) {
</div> </div>
)} )}
<<<<<<< HEAD
{/* Timecode overlay */}
{onAir && (
<div className="po-tc-overlay mono">{fmtTimecode(elapsed)}</div>
)}
{/* Audio meters */}
<div className="po-meters-wrap">
<AudioMeter onAir={onAir} />
=======
{onAir && ( {onAir && (
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div> <div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
)} )}
<div className="po-meters-wrap"> <div className="po-meters-wrap">
<PoAudioMeter onAir={onAir} /> <PoAudioMeter onAir={onAir} />
>>>>>>> main
</div> </div>
</div> </div>
@ -690,12 +711,6 @@ function Scte35Panel({ channel, engine, breaks, onReload, onError }) {
<div className="po-card po-scte-card"> <div className="po-card po-scte-card">
<div className="po-card-head"> <div className="po-card-head">
<span className="po-section-label">SCTE-35 Break</span> <span className="po-section-label">SCTE-35 Break</span>
{scte
? <span className="po-scte-stub-badge" style={{ color: '#f59e0b' }}> ON AIR</span>
: pending.length > 0
? <span className="po-scte-stub-badge">{pending.length} queued</span>
: null}
</div>
<div className="po-scte-body"> <div className="po-scte-body">
{scte && ( {scte && (
<div className="po-scte-active mono" style={{ color: '#f59e0b', fontWeight: 600, fontSize: 12 }}> <div className="po-scte-active mono" style={{ color: '#f59e0b', fontWeight: 600, fontSize: 12 }}>
@ -745,6 +760,10 @@ function NowPlayingCard({ engine, elapsed, items }) {
const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0; const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0;
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0; const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0; const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
<<<<<<< HEAD
=======
>>>>>>> main
const nextItem = items[engine.currentIndex + 1] || null; const nextItem = items[engine.currentIndex + 1] || null;
return ( return (
@ -858,6 +877,159 @@ function Timeline({ items, activeIndex, elapsed, breaks }) {
); );
} }
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 (
<div className="po-pgm">
<div className="po-screen">
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
{/* ON AIR / SCTE BREAK badge */}
{onAir && scte && (
<div className="po-onair-badge" style={{ background: '#f59e0b' }}>
SCTE BREAK{breakRemain > 0 ? ' ' + Math.ceil(breakRemain) + 's' : ''}
</div>
)}
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
{!onAir && (
<div className="po-screen-offline">
<span className="po-screen-offline-dot" />
<span>Channel stopped</span>
</div>
)}
{onAir && (
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
)}
<div className="po-meters-wrap">
<PoAudioMeter onAir={onAir} />
</div>
</div>
<div className="po-tl-empty muted">Add clips to the playlist to see the timeline.</div>
</div>
);
}
<<<<<<< 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 (
<div className="po-tl">
<div className="po-tl-head">
<span className="po-section-label">Timeline</span>
<<<<<<< HEAD
<span className="mono muted" style={{ fontSize: 11 }}>{fmtDuration(totalSecs)} total</span>
</div>
<div className="po-tl-track-wrap">
{/* Playhead */}
{activeIndex >= 0 && (
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
)}
=======
<span className="mono muted" style={{ fontSize: 11 }}>{playoutFmtDur(totalSecs)} total</span>
</div>
<div className="po-tl-track-wrap">
{activeIndex >= 0 && (
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
)}
{breakMarkers.map(m => (
<div key={m.id} className="po-tl-scte-marker" style={{ left: m.pct + '%' }}
title={'SCTE-35 break · ' + m.dur + 's'} />
))}
>>>>>>> main
<div className="po-tl-track">
{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 (
<div key={it.id}
className={'po-tl-clip' + (isActive ? ' po-tl-clip--active' : '')}
style={{ width: pct + '%', '--clip-color': color }}
<<<<<<< HEAD
title={`${it.clip_name || it.asset_id} · ${fmtDuration(dur)}`}>
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
<span className="po-tl-clip-dur mono">{fmtDuration(dur)}</span>
=======
title={`${it.clip_name || it.asset_id} · ${playoutFmtDur(dur)}`}>
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
<span className="po-tl-clip-dur mono">{playoutFmtDur(dur)}</span>
>>>>>>> main
{it.media_status === 'staging' && (
<span className="po-tl-staging-dot" title="Staging…" />
)}
{it.media_status === 'error' && (
<span className="po-tl-error-dot" title="Stage error" />
)}
</div>
);
})}
</div>
<<<<<<< HEAD
{/* Time ruler (rough marks) */}
<div className="po-tl-ruler">
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
<span key={i} className="po-tl-ruler-mark mono"
style={{ left: (i * 25) + '%' }}>
{fmtDuration((totalSecs * i) / 4)}
=======
<div className="po-tl-ruler">
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
<span key={i} className="po-tl-ruler-mark mono" style={{ left: (i * 25) + '%' }}>
{playoutFmtDur((totalSecs * i) / 4)}
>>>>>>> main
</span>
))}
</div>
</div>
</div>
);
}
// As-run drawer // As-run drawer
function AsRunDrawer({ channel, refreshKey, open, onClose }) { function AsRunDrawer({ channel, refreshKey, open, onClose }) {
const [rows, setRows] = React.useState([]); const [rows, setRows] = React.useState([]);
@ -1007,6 +1179,15 @@ function ChannelDetail({ channel, onChannelChange }) {
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1; const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
const elapsed = useElapsed(engine && engine.currentItemStartedAt); const elapsed = useElapsed(engine && engine.currentItemStartedAt);
<<<<<<< HEAD
const onAir = ch.status === 'running';
return (
<div className="po-root">
{/* ── Top rail: monitor + right panel ── */}
<div className="po-top">
{/* PGM monitor + transport */}
=======
return ( return (
<div className="po-root"> <div className="po-root">
@ -1014,6 +1195,7 @@ function ChannelDetail({ channel, onChannelChange }) {
{/* ── Top rail: monitor + right panel ── */} {/* ── Top rail: monitor + right panel ── */}
<div className="po-top"> <div className="po-top">
>>>>>>> main
<div className="po-pgm-col"> <div className="po-pgm-col">
<ProgramMonitor channel={ch} engine={engine} elapsed={elapsed} /> <ProgramMonitor channel={ch} engine={engine} elapsed={elapsed} />
<Transport <Transport
@ -1021,10 +1203,17 @@ function ChannelDetail({ channel, onChannelChange }) {
playlistId={playlistId} playlistId={playlistId}
items={items} items={items}
onStatus={loadItems} onStatus={loadItems}
<<<<<<< HEAD
/>
</div>
{/* Right rail */}
=======
onError={setActionErr} onError={setActionErr}
/> />
</div> </div>
>>>>>>> main
<div className="po-rail"> <div className="po-rail">
{/* Channel controls */} {/* Channel controls */}
<div className="po-card po-channel-card"> <div className="po-card po-channel-card">
@ -1048,6 +1237,20 @@ function ChannelDetail({ channel, onChannelChange }) {
)} )}
</div> </div>
{ch.error_message && <div className="alert error" style={{ marginTop: 8 }}>{ch.error_message}</div>} {ch.error_message && <div className="alert error" style={{ marginTop: 8 }}>{ch.error_message}</div>}
<<<<<<< HEAD
</div>
{/* Now playing */}
<NowPlayingCard engine={engine} elapsed={elapsed} items={items} />
{/* SCTE-35 */}
<Scte35Panel channel={ch} />
{/* Quick actions */}
<div className="po-rail-actions">
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
{binOpen ? '▸ Hide' : '▾ Media Bin'}
=======
{actionErr && ( {actionErr && (
<div className="alert error" style={{ marginTop: 8 }} onClick={() => setActionErr(null)}> <div className="alert error" style={{ marginTop: 8 }} onClick={() => setActionErr(null)}>
{actionErr} {actionErr}
@ -1063,6 +1266,7 @@ function ChannelDetail({ channel, onChannelChange }) {
<div className="po-rail-actions"> <div className="po-rail-actions">
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}> <button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
{binOpen ? '▸ Hide bin' : '▾ Media Bin'} {binOpen ? '▸ Hide bin' : '▾ Media Bin'}
>>>>>>> main
</button> </button>
<button className="btn ghost sm po-rail-action-btn" onClick={() => setAsRunOpen(true)}> <button className="btn ghost sm po-rail-action-btn" onClick={() => setAsRunOpen(true)}>
As-Run Log As-Run Log
@ -1071,8 +1275,16 @@ function ChannelDetail({ channel, onChannelChange }) {
</div> </div>
</div> </div>
<<<<<<< HEAD
{/* Media bin (collapsible, below top rail) */}
{binOpen && (
<MediaBin projectId={ch.project_id} />
)}
=======
{binOpen && <MediaBin projectId={ch.project_id} />} {binOpen && <MediaBin projectId={ch.project_id} />}
>>>>>>> main
{/* Playlist */}
{playlistId && ( {playlistId && (
<Playlist <Playlist
channel={ch} channel={ch}
@ -1083,8 +1295,15 @@ function ChannelDetail({ channel, onChannelChange }) {
/> />
)} )}
<<<<<<< HEAD
{/* Timeline */}
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} />
{/* As-run drawer */}
=======
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} breaks={breaks} /> <Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} breaks={breaks} />
>>>>>>> main
<AsRunDrawer <AsRunDrawer
channel={ch} channel={ch}
refreshKey={engine && engine.currentItemId} refreshKey={engine && engine.currentItemId}
@ -1147,6 +1366,8 @@ function Playout() {
</div> </div>
<div className="page-body po-page"> <div className="page-body po-page">
<<<<<<< HEAD
=======
<div style={{ <div style={{
background: '#fef3c7', background: '#fef3c7',
borderLeft: '4px solid #f59e0b', borderLeft: '4px solid #f59e0b',
@ -1162,6 +1383,7 @@ function Playout() {
}}> }}>
Playout is in testing not for production use. Playout is in testing not for production use.
</div> </div>
>>>>>>> main
{err && <div className="alert error">{err}</div>} {err && <div className="alert error">{err}</div>}
{channels === null && <div className="muted">Loading channels</div>} {channels === null && <div className="muted">Loading channels</div>}

View file

@ -539,7 +539,6 @@
padding-bottom: 8px; border-bottom: 1px solid var(--border); padding-bottom: 8px; border-bottom: 1px solid var(--border);
margin-bottom: 10px; margin-bottom: 10px;
} }
/* ── SCTE-35 additions (timeline marker + active-break line) ──────────────── */ /* ── SCTE-35 additions (timeline marker + active-break line) ──────────────── */
.po-tl-scte-marker { .po-tl-scte-marker {
position: absolute; top: 10px; bottom: 28px; position: absolute; top: 10px; bottom: 28px;

View file

@ -58,6 +58,9 @@ function AssetThumb({ asset, size = 'md' }) {
); );
} }
// VOD HLS assets: if we have an HLS rendition, we could potentially show a
// muted hover-preview here too. For now, just static thumb.
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail'; const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
return ( return (
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}> <div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
@ -112,7 +115,12 @@ function LiveThumb({ assetId, aspect }) {
const startHls = () => { const startHls = () => {
if (destroyed) return; if (destroyed) return;
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true, maxBufferLength: 10 }); hls = new window.Hls({
liveSyncDurationCount: 2,
lowLatencyMode: true,
maxBufferLength: 10,
xhrSetup: (xhr) => { xhr.withCredentials = true; }
});
hls.loadSource(url); hls.loadSource(url);
hls.attachMedia(v); hls.attachMedia(v);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => { hls.on(window.Hls.Events.MANIFEST_PARSED, () => {