2026-04-07 21:58:22 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-05-16 16:48:25 -04:00
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-05-18 10:13:08 -04:00
< link rel = "icon" type = "image/x-icon" href = "favicon.ico" >
2026-05-18 22:56:51 -04:00
< title > Capture — Z-AMPP< / title >
2026-05-16 16:48:25 -04:00
< link rel = "stylesheet" href = "css/common.css" >
< style >
.capture-layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: var(--sp-6);
max-width: 960px;
}
/* Control panel */
.capture-controls {
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
/* Timecode display */
.timecode-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--sp-5) var(--sp-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
}
.timecode-label {
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.timecode-display {
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
font-family: var(--font-mono);
font-size: 64px;
font-weight: 500;
2026-05-16 16:48:25 -04:00
font-variant-numeric: tabular-nums;
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
letter-spacing: 0.05em;
2026-05-16 16:48:25 -04:00
color: var(--accent);
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
text-shadow: 0 0 18px oklch(55% 0.20 266 / 0.30);
2026-05-16 16:48:25 -04:00
line-height: 1;
}
.timecode-display.inactive {
color: var(--text-tertiary);
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
text-shadow: none;
2026-05-16 16:48:25 -04:00
}
.timecode-status {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-xs);
color: var(--text-secondary);
}
/* Record button */
.record-btn-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
}
.record-btn {
width: 80px;
height: 80px;
border-radius: 50%;
background: oklch(20% 0.010 25);
border: 3px solid oklch(30% 0.010 25);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
.record-btn:hover {
background: oklch(25% 0.012 25);
border-color: var(--status-red);
}
.record-btn.recording {
background: var(--status-red);
border-color: var(--status-red);
box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.20);
animation: rec-pulse 2s ease-out infinite;
}
.record-btn-inner {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--status-red);
transition: border-radius var(--t-fast), width var(--t-fast), height var(--t-fast);
}
.record-btn.recording .record-btn-inner {
border-radius: var(--r-sm);
width: 22px;
height: 22px;
background: oklch(98% 0.005 25);
}
@keyframes rec-pulse {
0% { box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.25); }
50% { box-shadow: 0 0 0 10px oklch(62% 0.22 25 / 0.08); }
100% { box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.25); }
}
.record-btn-label {
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: 500;
}
.record-btn-label.recording { color: var(--status-red); }
/* Settings panel */
.capture-settings {
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-4);
}
.settings-title {
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);
}
/* Status bar */
.status-bar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.status-bar-dot { flex-shrink: 0; }
.status-bar-text { flex: 1; }
.status-bar-file { font-family: 'SF Mono', monospace; font-size: 11px; color: var(--text-tertiary); }
/* Recent captures table */
.recent-section { display: flex; flex-direction: column; gap: var(--sp-3); }
.recent-title {
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
< / style >
2026-04-07 21:58:22 -04:00
< / head >
< body >
2026-05-16 16:48:25 -04:00
< div class = "shell" >
< nav class = "sidebar" aria-label = "Main navigation" >
< div class = "sidebar-brand" >
2026-05-18 10:13:08 -04:00
< img src = "img/dragon-logo.png?v=1" alt = "Wild Dragon" class = "sidebar-logo" >
2026-05-18 09:28:49 -04:00
< span class = "sidebar-brand-name" > Z-AMPP< / span >
2026-05-16 16:48:25 -04:00
< / div >
2026-05-18 22:56:51 -04:00
< nav class = "sidebar-nav" >
< a href = "home.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z" / > < / svg >
Home
< / a >
< a href = "index.html" class = "nav-item" >
2026-05-16 16:48:25 -04:00
< 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 >
2026-05-18 22:56:51 -04:00
< a href = "projects.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z" / > < / svg >
Projects
< / a >
2026-05-16 16:48:25 -04:00
< 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" >
< 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 active" >
< 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 >
2026-05-18 22:56:51 -04:00
< a href = "#" id = "editor-nav-link" class = "nav-item" target = "_blank" rel = "noopener" >
2026-05-17 21:44:15 -04:00
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3" / > < / svg >
Editor
< / a >
2026-05-18 22:56:51 -04:00
< div class = "sidebar-section-label" > Admin< / div >
< a href = "users.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "6" cy = "5" r = "2.5" / > < path d = "M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5" / > < circle cx = "12" cy = "5" r = "2" / > < path d = "M15 12c0-1.9-1.3-3.5-3-4" / > < / svg >
Users
< / a >
< a href = "tokens.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "6" cy = "10" r = "3.5" / > < path d = "M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1" / > < / svg >
Tokens
< / a >
2026-05-16 16:48:25 -04:00
< / nav >
2026-05-18 22:56:51 -04:00
< 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" title = "Sign out" style = "padding:0;width:24px;height:24px;flex-shrink:0;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" width = "14" height = "14" > < path d = "M10 8H3M6 5l-3 3 3 3" / > < path d = "M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7" / > < / svg >
< / button >
< / div >
< / div >
2026-05-16 16:48:25 -04:00
< / nav >
< div class = "main" >
< header class = "topbar" >
< div class = "topbar-left" >
< span class = "page-title" > Capture< / span >
< span class = "topbar-sep" > /< / span >
< span class = "text-sm text-secondary" > Direct SDI< / span >
< / div >
< div class = "topbar-right" >
< span class = "text-xs text-tertiary" id = "deviceStatus" > Loading devices…< / span >
< / div >
< / header >
< div class = "page-content" >
<!-- No - device empty state (shown when no SDI cards found) -->
< div id = "noDeviceState" 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 = "3" y = "10" width = "34" height = "22" rx = "2" / > < path d = "M13 32v4M27 32v4M9 36h22" / > < path d = "M16 19l4-3 4 3-1.5 5h-5L16 19z" stroke-opacity = "0.5" / > < / svg >
< / div >
< div class = "empty-state-title" > No SDI devices found< / div >
< div class = "empty-state-body" > This machine has no DeckLink cards installed. To ingest live streams via SRT or RTMP, use the Recorders page instead.< / div >
< div class = "empty-state-actions" >
< a href = "recorders.html" class = "btn btn-primary btn-sm" > Go to Recorders< / a >
< button class = "btn btn-ghost btn-sm" onclick = "initCapture()" > Retry< / button >
< / div >
< / div >
<!-- Main capture layout (shown when devices found) -->
< div class = "capture-layout" id = "captureLayout" style = "display:none;" >
<!-- Left: controls -->
< div class = "capture-controls" >
<!-- Timecode -->
< div class = "timecode-block" >
< div class = "timecode-label" > Elapsed< / div >
< div class = "timecode-display inactive" id = "timecodeDisplay" > 00:00:00:00< / div >
< div class = "timecode-status" >
< span class = "status-dot status-dot--idle" id = "recStatusDot" > < / span >
< span id = "recStatusLabel" > Ready< / span >
2026-04-07 21:58:22 -04:00
< / div >
2026-05-16 16:48:25 -04:00
< / div >
<!-- Record button -->
< div class = "record-btn-wrap" >
< button class = "record-btn" id = "recordBtn" onclick = "toggleRecord()" aria-label = "Start recording" title = "Start/stop recording" >
< div class = "record-btn-inner" > < / div >
< / button >
< div class = "record-btn-label" id = "recordBtnLabel" > Record< / div >
< / div >
<!-- Status bar -->
< div class = "status-bar" id = "statusBar" >
< span class = "status-bar-dot status-dot status-dot--idle" id = "statusDot" > < / span >
< span class = "status-bar-text" id = "statusText" > Select device and project to begin< / span >
< / div >
2026-04-07 21:58:22 -04:00
< / div >
2026-05-16 16:48:25 -04:00
<!-- Right: settings + recent -->
< div style = "display:flex;flex-direction:column;gap:var(--sp-5);" >
<!-- Settings -->
< div class = "capture-settings" >
< div class = "settings-title" > Source< / div >
< div class = "form-group" >
< label class = "form-label" for = "deviceSelect" > Device< / label >
< select id = "deviceSelect" > < / select >
< / div >
< / div >
< div class = "capture-settings" >
< div class = "settings-title" > Destination< / div >
< div class = "form-group" >
< label class = "form-label" for = "projectSelect" > Project< / label >
< select id = "projectSelect" >
< option value = "" > Select project…< / option >
< / select >
2026-04-07 21:58:22 -04:00
< / div >
2026-05-16 16:48:25 -04:00
< div class = "form-group" >
< label class = "form-label" for = "binSelect" > Bin< / label >
< select id = "binSelect" >
< option value = "" > Project root< / option >
< / select >
2026-04-07 21:58:22 -04:00
< / div >
2026-05-16 16:48:25 -04:00
< div class = "form-group" >
< label class = "form-label" for = "clipName" > Clip name< / label >
< input type = "text" id = "clipName" placeholder = "Auto-generated if blank" >
< / div >
< / div >
<!-- Recent captures -->
< div class = "recent-section" >
< div class = "recent-title" > Recent captures< / div >
< div id = "recentList" >
< div class = "empty-state" style = "padding:var(--sp-8) 0;" >
< div class = "empty-state-body" > No recent captures< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
2026-04-07 21:58:22 -04:00
< / div >
2026-05-16 16:48:25 -04:00
< / div >
< / div >
< div class = "toast-container" id = "toastContainer" aria-live = "polite" > < / div >
2026-05-17 12:55:36 -04:00
< script src = "js/api.js?v=5" > < / script >
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
< script src = "js/topbar-strip.js?v=1" > < / script >
2026-05-16 16:48:25 -04:00
< script >
const cState = {
devices: [],
projects: [],
currentDevice: null,
isRecording: false,
startedAt: null,
tcInterval: null,
};
document.addEventListener('DOMContentLoaded', () => {
initCapture();
document.getElementById('projectSelect').onchange = handleProjectChange;
});
async function initCapture() {
document.getElementById('deviceStatus').textContent = 'Loading devices…';
const [devRes, projRes, recRes] = await Promise.all([
getCaptureDevices(),
getProjects(),
getRecentCaptures(8),
]);
if (devRes.success) {
cState.devices = devRes.data;
renderDevices();
}
if (projRes.success) {
cState.projects = projRes.data;
const sel = document.getElementById('projectSelect');
sel.innerHTML = '< option value = "" > Select project…< / option > ' +
projRes.data.map(p => `< option value = "${p.id}" > ${esc(p.name)}< / option > `).join('');
}
if (recRes.success) renderRecent(recRes.data);
if (!cState.devices.length) {
document.getElementById('noDeviceState').style.display = 'flex';
document.getElementById('captureLayout').style.display = 'none';
document.getElementById('deviceStatus').textContent = 'No devices';
} else {
document.getElementById('noDeviceState').style.display = 'none';
document.getElementById('captureLayout').style.display = 'grid';
}
// Check if already recording
const statusRes = await getRecordingStatus();
if (statusRes.success & & statusRes.data?.recording) {
cState.isRecording = true;
cState.startedAt = new Date(statusRes.data.started_at || Date.now());
updateRecordingUI();
startTimecodeUpdate();
}
}
function renderDevices() {
const sel = document.getElementById('deviceSelect');
const status = document.getElementById('deviceStatus');
if (!cState.devices.length) { status.textContent = 'No devices'; return; }
sel.innerHTML = cState.devices.map(d => `< option value = "${d.id}" > ${esc(d.name)}< / option > `).join('');
cState.currentDevice = cState.devices[0].id;
status.textContent = `${cState.devices.length} device${cState.devices.length > 1 ? 's' : ''} available`;
}
async function handleProjectChange() {
const pid = document.getElementById('projectSelect').value;
const binSel = document.getElementById('binSelect');
binSel.innerHTML = '< option value = "" > Project root< / option > ';
if (!pid) return;
const r = await getBins(pid);
if (r.success) r.data.forEach(b => binSel.innerHTML += `< option value = "${b.id}" > ${esc(b.name)}< / option > `);
}
async function toggleRecord() {
if (cState.isRecording) {
await stopCap();
} else {
await startCap();
}
}
async function startCap() {
const device = document.getElementById('deviceSelect').value;
const projectId = document.getElementById('projectSelect').value;
if (!device) { toast('Select a device', '', 'warning'); return; }
setStatus('Starting…', 'processing');
const r = await startRecording(
device,
projectId || null,
document.getElementById('binSelect').value || null,
document.getElementById('clipName').value || null
);
if (r.success) {
cState.isRecording = true;
cState.startedAt = new Date();
updateRecordingUI();
startTimecodeUpdate();
setStatus('Recording', 'recording');
toast('Recording started', '', 'success');
} else {
setStatus('Error: ' + (r.error || 'failed to start'), 'error');
toast('Failed to start', r.error, 'error');
}
}
async function stopCap() {
setStatus('Stopping…', 'processing');
const r = await stopRecording();
if (r.success) {
cState.isRecording = false;
clearInterval(cState.tcInterval);
cState.tcInterval = null;
updateRecordingUI();
setStatus('Stopped — file saved', 'idle');
toast('Recording stopped', r.data?.filename || '', 'success');
setTimeout(() => initCapture(), 1500);
} else {
setStatus('Stop failed: ' + r.error, 'error');
toast('Failed to stop', r.error, 'error');
}
}
function updateRecordingUI() {
const btn = document.getElementById('recordBtn');
const label = document.getElementById('recordBtnLabel');
const tc = document.getElementById('timecodeDisplay');
const dot = document.getElementById('recStatusDot');
const statusLabel = document.getElementById('recStatusLabel');
if (cState.isRecording) {
btn.classList.add('recording');
btn.setAttribute('aria-label', 'Stop recording');
label.textContent = 'Stop';
label.classList.add('recording');
tc.classList.remove('inactive');
dot.className = 'status-dot status-dot--recording';
statusLabel.textContent = 'Recording';
} else {
btn.classList.remove('recording');
btn.setAttribute('aria-label', 'Start recording');
label.textContent = 'Record';
label.classList.remove('recording');
tc.classList.add('inactive');
tc.textContent = '00:00:00:00';
dot.className = 'status-dot status-dot--idle';
statusLabel.textContent = 'Ready';
}
}
function startTimecodeUpdate() {
if (cState.tcInterval) clearInterval(cState.tcInterval);
cState.tcInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - cState.startedAt) / 1000);
const h = Math.floor(elapsed / 3600);
const m = Math.floor((elapsed % 3600) / 60);
const s = elapsed % 60;
const f = Math.floor((Date.now() - cState.startedAt) % 1000 / (1000 / 30));
document.getElementById('timecodeDisplay').textContent =
[h, m, s, f].map(v => String(v).padStart(2,'0')).join(':');
}, 33);
}
function setStatus(text, type) {
document.getElementById('statusText').textContent = text;
const dot = document.getElementById('statusDot');
const dotClass = { recording:'status-dot--recording', processing:'status-dot--processing', error:'status-dot--error', idle:'status-dot--idle' }[type] || 'status-dot--idle';
dot.className = `status-bar-dot status-dot ${dotClass}`;
}
function renderRecent(captures) {
const list = document.getElementById('recentList');
if (!captures?.length) return;
list.innerHTML = `< table class = "data-table" >
< thead > < tr > < th > File< / th > < th > Duration< / th > < th > Date< / th > < / tr > < / thead >
< tbody > ${captures.map(c => `
< tr >
< td class = "truncate" style = "max-width:200px" > ${esc(c.filename || c.clip_name || 'untitled')}< / td >
< td > ${c.duration ? formatDuration(c.duration) : '—'}< / td >
< td > ${c.created_at ? new Date(c.created_at).toLocaleString() : '—'}< / td >
< / tr > `).join('')}
< / tbody >
< / table > `;
}
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 >
2026-05-18 13:27:24 -04:00
< script src = "js/auth-guard.js" > < / script >
2026-05-18 22:56:51 -04:00
< script >
(function(){
var el=document.getElementById('editor-nav-link');
if(el){el.href=location.protocol+'//'+location.hostname+':47435/';}
})();
< / script >
2026-04-07 21:58:22 -04:00
< / body >
< / html >