HLS VOD playback for browser (supplements MP4 proxy) #170
6 changed files with 424 additions and 16 deletions
|
|
@ -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`,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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, () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue