Replace inline auth script with shared auth-guard.js in recorders.html
This commit is contained in:
parent
936867c0c3
commit
81b832dc70
1 changed files with 763 additions and 1 deletions
|
|
@ -1 +1,763 @@
|
||||||
recorders_fixed
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Recorders — Wild Dragon</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/common.css">
|
||||||
|
<style>
|
||||||
|
/* Recorder grid */
|
||||||
|
.recorder-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recorder card */
|
||||||
|
.recorder-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
transition: border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-card.recording {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-card.error {
|
||||||
|
border-color: oklch(62% 0.22 25 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card header */
|
||||||
|
.recorder-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-id {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-name {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status row */
|
||||||
|
.recorder-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-timer {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-timer.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Source info */
|
||||||
|
.recorder-source {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection info banner */
|
||||||
|
.recorder-connect-info {
|
||||||
|
margin-top: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card footer */
|
||||||
|
.recorder-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding-top: var(--sp-2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-footer-meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form sections */
|
||||||
|
.form-section-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding-bottom: var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Source type radio row */
|
||||||
|
.source-type-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-type-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color var(--t-fast), background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-type-btn:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-type-btn.active {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode selector */
|
||||||
|
.mode-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.mode-btn.active { border-color: var(--accent-border); background: var(--accent-subtle); color: var(--accent); font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="sidebar" aria-label="Main navigation">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="sidebar-brand-mark">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="sidebar-brand-name">Wild Dragon</span>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="index.html" class="nav-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||||
|
Library
|
||||||
|
</a>
|
||||||
|
<a href="upload.html" class="nav-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
||||||
|
Ingest
|
||||||
|
</a>
|
||||||
|
<a href="recorders.html" class="nav-item active">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
||||||
|
Recorders
|
||||||
|
</a>
|
||||||
|
<a href="capture.html" class="nav-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
||||||
|
Capture
|
||||||
|
</a>
|
||||||
|
<a href="jobs.html" class="nav-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
||||||
|
Jobs
|
||||||
|
</a>
|
||||||
|
<div class="sidebar-section-label">Admin</div>
|
||||||
|
<a href="settings.html" class="nav-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.2 3.2l1 1M11.8 11.8l1 1M3.2 12.8l1-1M11.8 4.2l1-1"/></svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<a href="users.html" class="nav-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6"/></svg>
|
||||||
|
Users
|
||||||
|
</a>
|
||||||
|
<a href="tokens.html" class="nav-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="10" height="8" rx="1"/><path d="M6 6V4a2 2 0 0 1 4 0v2"/></svg>
|
||||||
|
Tokens
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
||||||
|
<div class="sidebar-user-info">
|
||||||
|
<div class="sidebar-user-name" id="userName">—</div>
|
||||||
|
<div class="sidebar-user-role" id="userRole"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost" id="logoutBtn" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<span class="page-title">Recorders</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button class="btn btn-primary btn-sm" id="newRecorderBtn">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
|
||||||
|
New recorder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<div id="recorderGrid" class="recorder-grid"></div>
|
||||||
|
<div id="recorderEmpty" class="empty-state" style="display:none;">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="9" width="26" height="22" rx="2"/><path d="M28 16l10-5v18l-10-5"/><circle cx="15" cy="20" r="4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state-title">No recorders yet</div>
|
||||||
|
<div class="empty-state-body">Create a recorder to ingest live streams via SRT, RTMP, or SDI.</div>
|
||||||
|
<div class="empty-state-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openPanel()">New recorder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide panel: new/edit recorder -->
|
||||||
|
<div class="slide-overlay" id="panelOverlay"></div>
|
||||||
|
<div class="slide-panel" id="recorderPanel">
|
||||||
|
<div class="slide-panel-header">
|
||||||
|
<span class="slide-panel-title">New recorder</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="closePanelBtn" style="padding:0;width:28px;height:28px;">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="slide-panel-body">
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recName">Recorder name</label>
|
||||||
|
<input type="text" id="recName" placeholder="e.g. Studio A SRT">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source type -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source type</label>
|
||||||
|
<div class="source-type-row">
|
||||||
|
<button class="source-type-btn active" data-type="srt" onclick="setSourceType('srt')">SRT</button>
|
||||||
|
<button class="source-type-btn" data-type="rtmp" onclick="setSourceType('rtmp')">RTMP</button>
|
||||||
|
<button class="source-type-btn" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic source config -->
|
||||||
|
<div id="sourceConfigFields" class="conditional-fields"></div>
|
||||||
|
|
||||||
|
<!-- Recording settings -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-section-label">Recording settings</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recCodec">Codec</label>
|
||||||
|
<select id="recCodec">
|
||||||
|
<option value="prores_hq">ProRes HQ</option>
|
||||||
|
<option value="prores_422">ProRes 422</option>
|
||||||
|
<option value="prores_lt">ProRes LT</option>
|
||||||
|
<option value="h264">H.264</option>
|
||||||
|
<option value="dnxhd">DNxHD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recResolution">Resolution</label>
|
||||||
|
<select id="recResolution">
|
||||||
|
<option value="native">Native (source)</option>
|
||||||
|
<option value="1920x1080">1920x1080</option>
|
||||||
|
<option value="1280x720">1280x720</option>
|
||||||
|
<option value="3840x2160">3840x2160</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="proxyToggle">
|
||||||
|
<div class="toggle-track"></div>
|
||||||
|
<span class="toggle-label">Generate proxy on stop</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proxy settings (shown when proxy enabled) -->
|
||||||
|
<div id="proxyFields" style="display:none;" class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="proxyCodec">Proxy codec</label>
|
||||||
|
<select id="proxyCodec">
|
||||||
|
<option value="h264">H.264</option>
|
||||||
|
<option value="h265">H.265</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="proxyBitrate">Proxy bitrate</label>
|
||||||
|
<select id="proxyBitrate">
|
||||||
|
<option value="2000k">2 Mbps</option>
|
||||||
|
<option value="4000k">4 Mbps</option>
|
||||||
|
<option value="8000k">8 Mbps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project / bin -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-section-label">Destination</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recProject">Project</label>
|
||||||
|
<select id="recProject">
|
||||||
|
<option value="">None (manual assignment)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recBin">Bin</label>
|
||||||
|
<select id="recBin">
|
||||||
|
<option value="">Project root</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="slide-panel-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="closePanel()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="saveRecorderBtn">Create recorder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script>
|
||||||
|
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'listener', projects: [] };
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await Promise.all([loadRecorders(), loadProjects()]);
|
||||||
|
setInterval(loadRecorders, 5000);
|
||||||
|
|
||||||
|
document.getElementById('newRecorderBtn').onclick = openPanel;
|
||||||
|
document.getElementById('closePanelBtn').onclick = closePanel;
|
||||||
|
document.getElementById('panelOverlay').onclick = closePanel;
|
||||||
|
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
|
||||||
|
document.getElementById('proxyToggle').onchange = e => {
|
||||||
|
document.getElementById('proxyFields').style.display = e.target.checked ? 'grid' : 'none';
|
||||||
|
};
|
||||||
|
document.getElementById('recProject').onchange = handleProjectChange;
|
||||||
|
updateSourceFields();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load / render
|
||||||
|
async function loadRecorders() {
|
||||||
|
const r = await getRecorders();
|
||||||
|
if (!r.success) return;
|
||||||
|
pState.recorders = r.data;
|
||||||
|
renderRecorders();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecorders() {
|
||||||
|
const grid = document.getElementById('recorderGrid');
|
||||||
|
const empty = document.getElementById('recorderEmpty');
|
||||||
|
|
||||||
|
if (!pState.recorders.length) {
|
||||||
|
grid.innerHTML = ''; empty.style.display = 'flex'; return;
|
||||||
|
}
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
grid.innerHTML = pState.recorders.map(rec => {
|
||||||
|
const isRecording = rec.status === 'recording';
|
||||||
|
const cfg = rec.source_config || {};
|
||||||
|
const sourceTypeKey = (rec.source_type || 'sdi').toLowerCase();
|
||||||
|
const badgeClass = { sdi:'badge-sdi', srt:'badge-srt', rtmp:'badge-rtmp' }[sourceTypeKey] || 'badge-idle';
|
||||||
|
const statusClass = isRecording ? 'recording' : rec.status === 'error' ? 'error' : '';
|
||||||
|
const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle';
|
||||||
|
|
||||||
|
let sourceDisplay = '';
|
||||||
|
if (cfg.mode === 'listener') {
|
||||||
|
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 9000 : 1935);
|
||||||
|
sourceDisplay = `Listen :${port}`;
|
||||||
|
} else if (cfg.url) {
|
||||||
|
sourceDisplay = cfg.url;
|
||||||
|
} else if (cfg.device !== undefined) {
|
||||||
|
sourceDisplay = `DeckLink ${cfg.device}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let connectBanner = '';
|
||||||
|
if (!isRecording && cfg.mode === 'listener') {
|
||||||
|
const serverIp = location.hostname || '10.0.0.25';
|
||||||
|
if (sourceTypeKey === 'srt') {
|
||||||
|
const port = cfg.listen_port || 9000;
|
||||||
|
connectBanner = `<div class="info-banner recorder-connect-info">
|
||||||
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
||||||
|
<span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>
|
||||||
|
</div>`;
|
||||||
|
} else if (sourceTypeKey === 'rtmp') {
|
||||||
|
const port = cfg.listen_port || 1935;
|
||||||
|
const key = cfg.stream_key || 'stream';
|
||||||
|
connectBanner = `<div class="info-banner recorder-connect-info">
|
||||||
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
||||||
|
<span>Push to <code>rtmp://${serverIp}:${port}/live/${key}</code></span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastRec = rec.last_recording_at ? new Date(rec.last_recording_at).toLocaleString() : 'Never';
|
||||||
|
|
||||||
|
return `<div class="recorder-card ${statusClass}" data-id="${rec.id}">
|
||||||
|
<div class="recorder-header">
|
||||||
|
<div class="recorder-id">
|
||||||
|
<div class="recorder-name">${esc(rec.name)}</div>
|
||||||
|
<div class="recorder-badges">
|
||||||
|
<span class="badge ${badgeClass}">${sourceTypeKey.toUpperCase()}</span>
|
||||||
|
${rec.codec ? `<span class="badge badge-idle">${esc(rec.codec)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recorder-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="handleDeleteRecorder('${rec.id}')" title="Delete recorder" style="padding:0;width:28px;height:28px;">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recorder-status-row">
|
||||||
|
<span class="status-dot ${statusDotClass}"></span>
|
||||||
|
<span class="text-sm" style="color:${isRecording ? 'var(--accent)' : rec.status === 'error' ? 'var(--status-red)' : 'var(--text-secondary)'}">
|
||||||
|
${isRecording ? 'Recording' : rec.status === 'error' ? 'Error' : 'Idle'}
|
||||||
|
</span>
|
||||||
|
${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recorder-source">
|
||||||
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M2 4h10M2 8h7M2 12h5"/></svg>
|
||||||
|
<span>${esc(sourceDisplay)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${connectBanner}
|
||||||
|
|
||||||
|
<div class="recorder-footer">
|
||||||
|
<span class="recorder-footer-meta">Last: ${lastRec}</span>
|
||||||
|
<div class="recorder-controls">
|
||||||
|
${isRecording
|
||||||
|
? `<button class="btn btn-danger btn-sm" onclick="handleStop('${rec.id}')">Stop</button>`
|
||||||
|
: `<button class="btn btn-record btn-sm" onclick="handleStart('${rec.id}')">
|
||||||
|
<svg viewBox="0 0 14 14" fill="currentColor" width="10" height="10"><circle cx="7" cy="7" r="5"/></svg>
|
||||||
|
Record
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
|
||||||
|
if (!pState.timers[rec.id]) {
|
||||||
|
const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();
|
||||||
|
pState.timers[rec.id] = setInterval(() => {
|
||||||
|
const el = document.getElementById(`timer-${rec.id}`);
|
||||||
|
if (!el) { clearInterval(pState.timers[rec.id]); delete pState.timers[rec.id]; return; }
|
||||||
|
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
|
el.textContent = formatDur(elapsed);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(pState.timers).forEach(id => {
|
||||||
|
if (!pState.recorders.find(r => r.id === id && r.status === 'recording')) {
|
||||||
|
clearInterval(pState.timers[id]); delete pState.timers[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDur(s) {
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||||
|
return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
async function handleStart(id) {
|
||||||
|
const r = await startRecorder(id);
|
||||||
|
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
|
||||||
|
else toast('Failed to start', r.error, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop(id) {
|
||||||
|
const r = await stopRecorder(id);
|
||||||
|
if (r.success) { toast('Recording stopped', '', 'success'); loadRecorders(); }
|
||||||
|
else toast('Failed to stop', r.error, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRecorder(id) {
|
||||||
|
if (!confirm('Delete this recorder?')) return;
|
||||||
|
const r = await deleteRecorder(id);
|
||||||
|
if (r.success) { toast('Recorder deleted', '', 'success'); loadRecorders(); }
|
||||||
|
else toast('Delete failed', r.error, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel
|
||||||
|
function openPanel() {
|
||||||
|
document.getElementById('recorderPanel').classList.add('open');
|
||||||
|
document.getElementById('panelOverlay').classList.add('open');
|
||||||
|
updateSourceFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
document.getElementById('recorderPanel').classList.remove('open');
|
||||||
|
document.getElementById('panelOverlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source type
|
||||||
|
function setSourceType(type) {
|
||||||
|
pState.sourceType = type;
|
||||||
|
pState.mode = 'listener';
|
||||||
|
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type));
|
||||||
|
updateSourceFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSourceFields() {
|
||||||
|
const container = document.getElementById('sourceConfigFields');
|
||||||
|
const type = pState.sourceType;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (type === 'sdi') {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="sdiDevice">DeckLink device</label>
|
||||||
|
<select id="sdiDevice">
|
||||||
|
<option value="0">DeckLink Card 1</option>
|
||||||
|
<option value="1">DeckLink Card 2</option>
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
} else if (type === 'srt') {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Mode</label>
|
||||||
|
<div class="mode-row">
|
||||||
|
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button>
|
||||||
|
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="srtListenerFields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="srtPort">Listen port (UDP)</label>
|
||||||
|
<input type="number" id="srtPort" value="9000" min="1024" max="65535">
|
||||||
|
<div class="form-hint">Encoders connect to this port on the server</div>
|
||||||
|
</div>
|
||||||
|
<div id="srtConnectInfo" class="info-banner">
|
||||||
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
||||||
|
<span>Encoder connect string: <code id="srtConnectStr">srt://10.0.0.25:9000?mode=caller</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="srtCallerFields" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="srtUrl">Source URL</label>
|
||||||
|
<input type="url" id="srtUrl" placeholder="srt://192.168.1.100:4200?mode=caller">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const portIn = document.getElementById('srtPort');
|
||||||
|
if (portIn) portIn.addEventListener('input', () => {
|
||||||
|
const el = document.getElementById('srtConnectStr');
|
||||||
|
if (el) el.textContent = `srt://${location.hostname || '10.0.0.25'}:${portIn.value}?mode=caller`;
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
} else if (type === 'rtmp') {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Mode</label>
|
||||||
|
<div class="mode-row">
|
||||||
|
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button>
|
||||||
|
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="rtmpListenerFields">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="rtmpPort">Listen port (TCP)</label>
|
||||||
|
<input type="number" id="rtmpPort" value="1935" min="1024" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="rtmpKey">Stream key</label>
|
||||||
|
<input type="text" id="rtmpKey" value="stream" placeholder="stream">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="rtmpConnectInfo" class="info-banner">
|
||||||
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
||||||
|
<span>Push to: <code id="rtmpConnectStr">rtmp://10.0.0.25:1935/live/stream</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="rtmpCallerFields" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="rtmpUrl">Source URL</label>
|
||||||
|
<input type="url" id="rtmpUrl" placeholder="rtmp://192.168.1.100/live/key">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const portIn = document.getElementById('rtmpPort');
|
||||||
|
const keyIn = document.getElementById('rtmpKey');
|
||||||
|
const update = () => {
|
||||||
|
const el = document.getElementById('rtmpConnectStr');
|
||||||
|
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 1935}/live/${keyIn?.value || 'stream'}`;
|
||||||
|
};
|
||||||
|
portIn?.addEventListener('input', update);
|
||||||
|
keyIn?.addEventListener('input', update);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(mode) {
|
||||||
|
pState.mode = mode;
|
||||||
|
document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
|
||||||
|
const type = pState.sourceType;
|
||||||
|
if (type === 'srt') {
|
||||||
|
document.getElementById('srtListenerFields').style.display = mode === 'listener' ? '' : 'none';
|
||||||
|
document.getElementById('srtCallerFields').style.display = mode === 'caller' ? '' : 'none';
|
||||||
|
} else if (type === 'rtmp') {
|
||||||
|
document.getElementById('rtmpListenerFields').style.display = mode === 'listener' ? '' : 'none';
|
||||||
|
document.getElementById('rtmpCallerFields').style.display = mode === 'caller' ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects for recorder destination
|
||||||
|
async function loadProjects() {
|
||||||
|
const r = await getProjects();
|
||||||
|
if (!r.success) return;
|
||||||
|
pState.projects = r.data;
|
||||||
|
const sel = document.getElementById('recProject');
|
||||||
|
sel.innerHTML = '<option value="">None (manual assignment)</option>' +
|
||||||
|
r.data.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProjectChange() {
|
||||||
|
const projectId = document.getElementById('recProject').value;
|
||||||
|
const binSel = document.getElementById('recBin');
|
||||||
|
binSel.innerHTML = '<option value="">Project root</option>';
|
||||||
|
if (!projectId) return;
|
||||||
|
const r = await getBins(projectId);
|
||||||
|
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save recorder
|
||||||
|
async function handleSaveRecorder() {
|
||||||
|
const name = document.getElementById('recName').value.trim();
|
||||||
|
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
|
||||||
|
|
||||||
|
const type = pState.sourceType;
|
||||||
|
const mode = pState.mode;
|
||||||
|
const codec = document.getElementById('recCodec').value;
|
||||||
|
const resolution = document.getElementById('recResolution').value;
|
||||||
|
const projectId = document.getElementById('recProject').value || null;
|
||||||
|
const binId = document.getElementById('recBin').value || null;
|
||||||
|
|
||||||
|
let sourceConfig = {};
|
||||||
|
if (type === 'sdi') {
|
||||||
|
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
|
||||||
|
} else if (type === 'srt') {
|
||||||
|
sourceConfig.mode = mode;
|
||||||
|
if (mode === 'listener') sourceConfig.listen_port = parseInt(document.getElementById('srtPort')?.value || '9000');
|
||||||
|
else sourceConfig.url = document.getElementById('srtUrl')?.value;
|
||||||
|
} else if (type === 'rtmp') {
|
||||||
|
sourceConfig.mode = mode;
|
||||||
|
if (mode === 'listener') {
|
||||||
|
sourceConfig.listen_port = parseInt(document.getElementById('rtmpPort')?.value || '1935');
|
||||||
|
sourceConfig.stream_key = document.getElementById('rtmpKey')?.value || 'stream';
|
||||||
|
} else {
|
||||||
|
sourceConfig.url = document.getElementById('rtmpUrl')?.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = document.getElementById('proxyToggle').checked;
|
||||||
|
const proxyConfig = proxy ? {
|
||||||
|
codec: document.getElementById('proxyCodec').value,
|
||||||
|
bitrate: document.getElementById('proxyBitrate').value,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name, source_type: type, source_config: sourceConfig,
|
||||||
|
codec, resolution, project_id: projectId, bin_id: binId,
|
||||||
|
proxy_config: proxyConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = await createRecorder(payload);
|
||||||
|
if (r.success) {
|
||||||
|
toast('Recorder created', name, 'success');
|
||||||
|
closePanel();
|
||||||
|
document.getElementById('recName').value = '';
|
||||||
|
await loadRecorders();
|
||||||
|
} else toast('Failed to create recorder', r.error, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(title, msg, type = 'info') {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `toast toast--${type}`;
|
||||||
|
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg ? `<div class="toast-msg">${esc(msg)}</div>` : ''}</div>`;
|
||||||
|
document.getElementById('toastContainer').appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="js/auth-guard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue