907fd90a5e
web.py was 1700 lines, more than half of it a single embedded HTML string. The page now lives in webui.html (loaded once at startup), so the frontend gets real syntax highlighting and web.py is pure Python. Dockerfile copies the new file alongside web.py. Also: ASCII startup banner (the arrow glyph crashed web.py on Windows when stdout was redirected to a cp1252 file), and README fixes — document the ALSA PCM-name device fallback and drop the monitor device row, which the ALSA backend never supported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
964 lines
40 KiB
HTML
964 lines
40 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>ISR Archive</title>
|
||
<style>
|
||
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
|
||
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
|
||
/* skip link */
|
||
.skip{position:absolute;left:-999px;top:0;padding:6px 14px;background:var(--accent);
|
||
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-size:13px;font-weight:600}
|
||
.skip:focus{left:0}
|
||
/* sr-only */
|
||
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
|
||
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||
header{padding:16px 28px;border-bottom:1px solid var(--brd);
|
||
display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||
header h1{font-size:18px;font-weight:600}
|
||
#subtitle{color:var(--muted);font-size:13px}
|
||
#storage-info{color:var(--muted);font-size:12px;margin-right:auto}
|
||
.controls-bar{display:flex;align-items:center;gap:10px;padding:10px 28px;
|
||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||
.controls-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.controls-bar input[type=number]{width:70px;background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||
.controls-bar input[type=number]:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
.controls-hint{font-size:11px;color:var(--muted)}
|
||
.wrap{padding:20px 28px}
|
||
table{width:100%;border-collapse:collapse}
|
||
th{text-align:left;padding:9px 10px;color:var(--muted);font-weight:500;font-size:12px;
|
||
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--brd);
|
||
white-space:nowrap}
|
||
td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
|
||
tr.data-row:hover td{background:var(--surf)}
|
||
.fn{font-family:ui-monospace,monospace;font-size:12px}
|
||
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
|
||
font-weight:700;text-transform:uppercase;margin-right:4px;border:1px solid}
|
||
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
|
||
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
|
||
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
|
||
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
|
||
.badge-rec{display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:3px;
|
||
font-size:10px;font-weight:700;text-transform:uppercase;margin-right:4px;
|
||
color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
|
||
animation:pulse 1.5s ease-in-out infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||
@media (prefers-reduced-motion:reduce){.badge-rec{animation:none}}
|
||
.muted{color:var(--muted)}
|
||
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
|
||
color:var(--txt);padding:4px 10px;border-radius:5px;font-size:12px;white-space:nowrap}
|
||
button:hover{background:var(--brd)}
|
||
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
button:disabled{opacity:.5;cursor:default}
|
||
a.dl{color:var(--accent);text-decoration:none;font-size:13px}
|
||
a.dl:hover{text-decoration:underline}
|
||
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||
.actions{display:flex;gap:6px;align-items:center}
|
||
button.del{color:var(--red);border-color:#7f1d1d}
|
||
button.del:hover:not(:disabled){background:#2d0808}
|
||
/* waveform */
|
||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
||
svg.wave{display:block;width:100%;height:56px}
|
||
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
||
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
||
button.chip{cursor:pointer}
|
||
button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
||
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
||
.prog-bar{height:3px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
|
||
.prog-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 0.15s ease}
|
||
.prog-file{font-size:12px;color:var(--muted);font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||
.analysis-meta{font-size:10px;color:var(--muted);margin-top:5px;font-style:italic}
|
||
button.reanalyse-btn{font-size:10px;padding:2px 7px;margin-top:6px;color:var(--muted);border-color:var(--brd);background:transparent}
|
||
button.reanalyse-btn:hover:not(:disabled){background:var(--surf);color:var(--txt)}
|
||
.empty{text-align:center;padding:60px;color:var(--muted)}
|
||
/* player row */
|
||
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
||
audio{width:100%;height:36px;border-radius:4px;display:block;
|
||
color-scheme:dark;accent-color:var(--accent)}
|
||
/* cut panel */
|
||
.cut-panel{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;
|
||
padding-top:8px;border-top:1px solid var(--brd)}
|
||
.cut-label{font-size:12px;color:var(--muted);white-space:nowrap}
|
||
.cut-field{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)}
|
||
.cut-time{width:90px;background:var(--bg);border:1px solid var(--brd);color:var(--txt);
|
||
padding:3px 6px;border-radius:4px;font-size:12px;font-family:ui-monospace,monospace}
|
||
.cut-time:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||
button.cut:hover:not(:disabled){background:#1e3a8a}
|
||
/* filter bar */
|
||
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
|
||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
|
||
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
/* day sections */
|
||
.day-section{margin-bottom:18px}
|
||
.day-heading-bar{background:var(--surf);padding:7px 10px;border:1px solid var(--brd);
|
||
border-radius:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.day-heading-bar.open{border-radius:6px 6px 0 0}
|
||
.day-toggle{background:none;border:none;color:var(--txt);font-size:13px;font-weight:600;
|
||
cursor:pointer;padding:2px 0;display:inline-flex;align-items:center;gap:8px;flex:1 1 auto}
|
||
.day-toggle:hover{color:var(--accent)}
|
||
.day-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||
.day-meta{color:var(--muted);font-size:12px;font-weight:400}
|
||
button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px}
|
||
button.day-hl:hover:not(:disabled){background:#0a3d1f}
|
||
button.day-hl:disabled{opacity:.5;cursor:default}
|
||
h2.day-heading{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit;flex:1 1 auto}
|
||
.day-hl-container{background:var(--bg);border:1px solid var(--brd);border-top:none;padding:8px 12px 12px}
|
||
table.day-table{width:100%;border-collapse:collapse;border:1px solid var(--brd);border-top:none}
|
||
svg.day-timeline{display:block;width:100%;height:22px}
|
||
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
|
||
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||
<a href="#main" class="skip">Skip to content</a>
|
||
<header>
|
||
<h1>ISR Archive</h1>
|
||
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
|
||
<span id="storage-info" aria-live="polite"></span>
|
||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||
</header>
|
||
<div class="controls-bar">
|
||
<label for="threshold-input">Analysis threshold:</label>
|
||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||
aria-describedby="threshold-hint">
|
||
<span id="threshold-hint" class="controls-hint">RMS amplitude 0–1 (linear; 0.05 ≈ −26 dBFS) · sections above this are marked loud</span>
|
||
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
|
||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
||
aria-describedby="preroll-hint">
|
||
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
|
||
<label for="min-gap-input" style="margin-left:16px">Grace period:</label>
|
||
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" value="2"
|
||
aria-describedby="min-gap-hint">
|
||
<span id="min-gap-hint" class="controls-hint">seconds — merge loud sections closer than this</span>
|
||
</div>
|
||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||
<label for="filter-name">Search:</label>
|
||
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
|
||
<label for="filter-from">From:</label>
|
||
<input type="date" id="filter-from" aria-label="From date">
|
||
<label for="filter-to">To:</label>
|
||
<input type="date" id="filter-to" aria-label="To date">
|
||
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
|
||
</div>
|
||
<div class="wrap" id="main">
|
||
<div id="tbody" role="region" aria-label="Recordings archive"></div>
|
||
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
|
||
</div>
|
||
<script>
|
||
const esc = s => String(s)
|
||
.replace(/&/g,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
|
||
const fmtDur = s => {
|
||
if (s == null) return '—';
|
||
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
|
||
return h?`${h}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
|
||
};
|
||
const fmtSize = b => {
|
||
if (b<1024) return b+' B';
|
||
if (b<1<<20) return (b/1024).toFixed(0)+' KB';
|
||
if (b<1<<30) return (b/(1<<20)).toFixed(1)+' MB';
|
||
return (b/(1<<30)).toFixed(2)+' GB';
|
||
};
|
||
const pad = n => String(n).padStart(2,'0');
|
||
|
||
function announce(msg) {
|
||
const el = document.getElementById('sr-announce');
|
||
if (!el) return;
|
||
el.textContent = ''; // clear first so same text re-triggers
|
||
setTimeout(() => { el.textContent = msg; }, 50);
|
||
}
|
||
const getPreroll = () => {
|
||
const v = parseFloat(document.getElementById('preroll-input').value);
|
||
return isNaN(v) || v < 0 ? 0 : v;
|
||
};
|
||
function setCutFields(idx, startSec, endSec) {
|
||
const startEl = document.getElementById('cut-start-'+idx);
|
||
const endEl = document.getElementById('cut-end-'+idx);
|
||
if (startEl) startEl.value = fmtDur(startSec);
|
||
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
|
||
}
|
||
|
||
// idx -> filename, for live-status polling
|
||
const recMap = new Map();
|
||
// idx -> [{start,end}], populated after analysis
|
||
const sectionMap = new Map();
|
||
let activePlayerIdx = null;
|
||
// full file list from server, annotated with stable _idx
|
||
let allFiles = [];
|
||
// dayId -> boolean, persists expanded state across re-renders
|
||
const dayExpanded = new Map();
|
||
// cross-file day section navigation (populated by ★ Highlights)
|
||
let dayActiveSections = [];
|
||
let dayActiveSectionCursor = -1;
|
||
let dayActiveId = null;
|
||
|
||
function groupByDay(files) {
|
||
const map = new Map();
|
||
files.forEach(f => {
|
||
const day = f.date.slice(0, 10);
|
||
if (!map.has(day)) map.set(day, []);
|
||
map.get(day).push(f);
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function closePlayer(idx) {
|
||
const prow = document.getElementById('prow-'+idx);
|
||
if (!prow || prow.hidden) return;
|
||
document.getElementById('aud-'+idx)?.pause();
|
||
prow.hidden = true;
|
||
const btn = document.getElementById('pbtn-'+idx);
|
||
if (btn) {
|
||
btn.setAttribute('aria-expanded','false');
|
||
btn.textContent = '▶ Play';
|
||
btn.setAttribute('aria-label','Play '+(recMap.get(idx) || ''));
|
||
}
|
||
}
|
||
|
||
function togglePlayer(idx, filename) {
|
||
const prow = document.getElementById('prow-'+idx);
|
||
const btn = document.getElementById('pbtn-'+idx);
|
||
const audio = document.getElementById('aud-'+idx);
|
||
const open = btn.getAttribute('aria-expanded') === 'true';
|
||
|
||
if (open) {
|
||
closePlayer(idx);
|
||
return;
|
||
}
|
||
if (!audio.getAttribute('data-src-set')) {
|
||
audio.preload = 'auto';
|
||
audio.src = '/stream/' + encodeURIComponent(filename);
|
||
audio.load();
|
||
audio.setAttribute('data-src-set','1');
|
||
}
|
||
activePlayerIdx = idx;
|
||
prow.hidden = false;
|
||
btn.setAttribute('aria-expanded','true');
|
||
btn.textContent = '⏹ Hide';
|
||
btn.setAttribute('aria-label','Hide player for '+filename);
|
||
audio.focus();
|
||
}
|
||
|
||
function drawWave(rms, sections, duration, filename) {
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const svg = document.createElementNS(ns,'svg');
|
||
svg.setAttribute('class','wave');
|
||
svg.setAttribute('viewBox',`0 0 ${rms.length} 1`);
|
||
svg.setAttribute('preserveAspectRatio','none');
|
||
svg.setAttribute('role','img');
|
||
const nSec = sections ? sections.length : 0;
|
||
svg.setAttribute('aria-label',
|
||
`Waveform for ${filename}: duration ${fmtDur(duration)}, ${nSec} loud section${nSec!==1?'s':''}`);
|
||
|
||
if (duration > 0 && sections) {
|
||
sections.forEach(s => {
|
||
const r = document.createElementNS(ns,'rect');
|
||
r.setAttribute('x', (s.start/duration)*rms.length);
|
||
r.setAttribute('y', 0);
|
||
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
|
||
r.setAttribute('height', 1);
|
||
r.setAttribute('fill','rgba(249,115,22,0.22)');
|
||
r.setAttribute('aria-hidden','true');
|
||
svg.appendChild(r);
|
||
});
|
||
}
|
||
const maxV = Math.max(...rms, 0.001);
|
||
rms.forEach((v,i) => {
|
||
const h = v/maxV;
|
||
const r = document.createElementNS(ns,'rect');
|
||
r.setAttribute('x',i); r.setAttribute('y',1-h);
|
||
r.setAttribute('width',1); r.setAttribute('height',h);
|
||
r.setAttribute('fill','#4f9cf9');
|
||
r.setAttribute('aria-hidden','true');
|
||
svg.appendChild(r);
|
||
});
|
||
return svg;
|
||
}
|
||
|
||
function parseTime(s) {
|
||
if (!s || !s.trim()) return null;
|
||
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||
if (parts.some(isNaN) || parts.length < 1 || parts.length > 3) return null;
|
||
if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
|
||
if (parts.length === 2) return parts[0]*60 + parts[1];
|
||
return parts[0];
|
||
}
|
||
|
||
function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||
const pbtn = document.getElementById('pbtn-'+idx);
|
||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||
activePlayerIdx = idx;
|
||
const audio = document.getElementById('aud-'+idx);
|
||
audio.preload = 'auto';
|
||
const seekTo = Math.max(0, startSec - getPreroll());
|
||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||
if (audio.readyState >= 1) doSeek();
|
||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||
setCutFields(idx, startSec, endSec);
|
||
if (sectionIdx != null) {
|
||
const total = (sectionMap.get(idx) || []).length;
|
||
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtDur(startSec)} to ${fmtDur(endSec)}`);
|
||
}
|
||
}
|
||
|
||
async function analyse(idx, filename, cell, btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||
const restoreBtn = () => {
|
||
btn.textContent = 'Analyse'; btn.disabled = false;
|
||
btn.onclick = () => analyse(idx, filename, cell, btn);
|
||
if (!cell.contains(btn)) cell.appendChild(btn);
|
||
};
|
||
try {
|
||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||
+'&threshold='+encodeURIComponent(threshold)
|
||
+'&min_gap='+encodeURIComponent(minGap));
|
||
const d = await r.json();
|
||
if (d.error) {
|
||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||
restoreBtn(); return;
|
||
}
|
||
const box = document.createElement('div'); box.className='wbox';
|
||
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
||
|
||
const meta = document.createElement('div'); meta.className='analysis-meta';
|
||
meta.textContent = `threshold: ${threshold} · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
|
||
box.appendChild(meta);
|
||
|
||
const chips = document.createElement('div');
|
||
chips.className='chips';
|
||
chips.setAttribute('role','group');
|
||
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
|
||
if (d.sections && d.sections.length) {
|
||
sectionMap.set(idx, d.sections);
|
||
d.sections.forEach((s, si) => {
|
||
const c = document.createElement('button');
|
||
c.className='chip';
|
||
c.title = 'Jump to this section (or use J/K keys)';
|
||
c.textContent = `${fmtDur(s.start)} – ${fmtDur(s.end)}`;
|
||
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
||
chips.appendChild(c);
|
||
});
|
||
} else {
|
||
sectionMap.delete(idx);
|
||
const q = document.createElement('span');
|
||
q.className='quiet';
|
||
q.textContent='No loud sections found';
|
||
chips.appendChild(q);
|
||
}
|
||
box.appendChild(chips);
|
||
|
||
const rebtn = document.createElement('button');
|
||
rebtn.className='reanalyse-btn'; rebtn.textContent='Re-analyse';
|
||
rebtn.onclick = () => analyse(idx, filename, cell, rebtn);
|
||
box.appendChild(rebtn);
|
||
|
||
cell.innerHTML=''; cell.appendChild(box);
|
||
} catch(e) {
|
||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(e.message)}</div>`;
|
||
restoreBtn();
|
||
}
|
||
}
|
||
|
||
// J = previous section, K = next section (only when focus is not in an input)
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return;
|
||
e.preventDefault();
|
||
|
||
// Day-level cross-file navigation when Highlights have been loaded
|
||
if (dayActiveSections.length) {
|
||
if (e.key === 'j' || e.key === 'J') {
|
||
const ni = dayActiveSectionCursor > 0 ? dayActiveSectionCursor - 1 : -1;
|
||
if (ni >= 0) jumpToDaySection(ni);
|
||
else announce('Beginning of day sections');
|
||
} else {
|
||
const ni = dayActiveSectionCursor + 1;
|
||
if (ni < dayActiveSections.length) jumpToDaySection(ni);
|
||
else announce('End of day sections');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Per-file section navigation
|
||
if (activePlayerIdx === null) return;
|
||
const sections = sectionMap.get(activePlayerIdx) || [];
|
||
if (!sections.length) return;
|
||
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||
if (!audio) return;
|
||
const preroll = getPreroll();
|
||
|
||
if (e.key === 'j' || e.key === 'J') {
|
||
const cur = audio.currentTime;
|
||
let targetIdx = -1;
|
||
for (let i = sections.length - 1; i >= 0; i--) {
|
||
if (sections[i].start < cur - 1) { targetIdx = i; break; }
|
||
}
|
||
if (targetIdx >= 0) {
|
||
const s = sections[targetIdx];
|
||
audio.currentTime = Math.max(0, s.start - preroll);
|
||
setCutFields(activePlayerIdx, s.start, s.end);
|
||
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
|
||
} else {
|
||
announce('Beginning of sections');
|
||
}
|
||
} else {
|
||
const cur = audio.currentTime;
|
||
let jumped = false;
|
||
for (let i = 0; i < sections.length; i++) {
|
||
if (sections[i].start > cur + preroll) {
|
||
const s = sections[i];
|
||
audio.currentTime = Math.max(0, s.start - preroll);
|
||
setCutFields(activePlayerIdx, s.start, s.end);
|
||
announce(`Section ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
|
||
jumped = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!jumped) announce('End of sections');
|
||
}
|
||
});
|
||
|
||
async function deleteFile(idx, filename) {
|
||
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
|
||
const btn = document.getElementById('delbtn-'+idx);
|
||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||
try {
|
||
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
|
||
if (r.ok) {
|
||
allFiles = allFiles.filter(f => f._idx !== idx);
|
||
recMap.delete(idx);
|
||
applyFilters();
|
||
updateStorage();
|
||
} else {
|
||
const d = await r.json().catch(()=>({}));
|
||
alert('Delete failed: '+(d.error||r.statusText));
|
||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||
}
|
||
} catch(e) {
|
||
alert('Delete failed: '+e.message);
|
||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||
}
|
||
}
|
||
|
||
async function updateStorage() {
|
||
try {
|
||
const s = await (await fetch('/api/storage')).json();
|
||
const el = document.getElementById('storage-info');
|
||
let txt = fmtSize(s.used) + ' used';
|
||
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
|
||
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
|
||
el.textContent = txt;
|
||
} catch(e) {}
|
||
}
|
||
|
||
function _attachFileRowHandlers(f, isRec, expanded, dayId) {
|
||
const i = f._idx;
|
||
const ext = f.ext;
|
||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||
|
||
if (canAnalyse) {
|
||
const cell = document.getElementById('wave-'+i);
|
||
const abtn = document.createElement('button');
|
||
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
|
||
if (isRec) {
|
||
abtn.textContent = 'Analyse';
|
||
abtn.disabled = true;
|
||
abtn.title = 'Recording in progress — analyse after recording stops';
|
||
cell.appendChild(abtn);
|
||
} else if (f.cached_analysis) {
|
||
abtn.textContent = 'Re-analyse';
|
||
cell.innerHTML = '<div class="spin">Loading…</div>';
|
||
analyse(i, f.name, cell, abtn);
|
||
} else {
|
||
abtn.textContent = 'Analyse';
|
||
abtn.onclick = () => analyse(i, f.name, cell, abtn);
|
||
cell.appendChild(abtn);
|
||
}
|
||
}
|
||
|
||
document.getElementById('pbtn-'+i)
|
||
.addEventListener('click', () => togglePlayer(i, f.name));
|
||
|
||
if (!isRec) {
|
||
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
|
||
const pbtn = document.getElementById('pbtn-'+i);
|
||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
|
||
document.getElementById('cut-start-'+i)?.focus();
|
||
});
|
||
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
|
||
const startStr = document.getElementById('cut-start-'+i).value;
|
||
const endStr = document.getElementById('cut-end-'+i).value;
|
||
const start = parseTime(startStr);
|
||
const end = parseTime(endStr);
|
||
if (start === null || end === null) {
|
||
alert('Enter start and end times, e.g. 1:30 or 0:01:30');
|
||
return;
|
||
}
|
||
if (start >= end) { alert('Start must be before end'); return; }
|
||
window.location.href =
|
||
'/api/cut?file=' + encodeURIComponent(f.name) +
|
||
'&start=' + start + '&end=' + end;
|
||
});
|
||
document.getElementById('delbtn-'+i)
|
||
.addEventListener('click', () => deleteFile(i, f.name));
|
||
}
|
||
|
||
recMap.set(i, f.name);
|
||
}
|
||
|
||
function renderFiles(files) {
|
||
const container = document.getElementById('tbody');
|
||
container.innerHTML = '';
|
||
recMap.clear();
|
||
sectionMap.clear();
|
||
|
||
const total = allFiles.length;
|
||
const visible = files.length;
|
||
document.getElementById('subtitle').textContent = total === visible
|
||
? `${total} recording${total!==1?'s':''} found`
|
||
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||
document.getElementById('empty').style.display = visible ? 'none' : '';
|
||
|
||
const days = groupByDay(files);
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
|
||
days.forEach((dayFiles, day) => {
|
||
const dayId = 'day-' + day.replace(/-/g, '');
|
||
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === today);
|
||
const expanded = dayExpanded.get(dayId);
|
||
|
||
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
|
||
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
|
||
const canHl = dayFiles.some(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||
const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : '';
|
||
const sizeStr = ' · ' + fmtSize(totalSize);
|
||
const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`;
|
||
|
||
// Day section wrapper
|
||
const section = document.createElement('div');
|
||
section.className = 'day-section';
|
||
section.id = 'daysec-' + dayId;
|
||
|
||
// Heading bar
|
||
const headBar = document.createElement('div');
|
||
headBar.className = 'day-heading-bar' + (expanded ? ' open' : '');
|
||
headBar.id = 'dayhead-' + dayId;
|
||
headBar.innerHTML = `
|
||
<h2 class="day-heading">
|
||
<button class="day-toggle" id="daytgl-${dayId}"
|
||
aria-expanded="${expanded}"
|
||
aria-controls="daytbl-${dayId}">
|
||
<span class="day-arrow" aria-hidden="true">${expanded ? '▼' : '▶'}</span>
|
||
${esc(day)}
|
||
<span class="day-meta">${fileStr}${durStr}${sizeStr}</span>
|
||
</button>
|
||
</h2>
|
||
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
|
||
aria-label="Show day highlights for ${esc(day)}">★ Highlights</button>` : ''}`;
|
||
section.appendChild(headBar);
|
||
|
||
// Highlights panel (hidden until button clicked)
|
||
const hlDiv = document.createElement('div');
|
||
hlDiv.className = 'day-hl-container';
|
||
hlDiv.id = 'dayhl-' + dayId;
|
||
hlDiv.hidden = true;
|
||
hlDiv.innerHTML = `<div id="dayhlc-${dayId}"></div>`;
|
||
section.appendChild(hlDiv);
|
||
|
||
// Per-day table
|
||
const table = document.createElement('table');
|
||
table.className = 'day-table';
|
||
table.id = 'daytbl-' + dayId;
|
||
table.setAttribute('aria-label', `Recordings for ${day}`);
|
||
if (!expanded) table.hidden = true;
|
||
table.innerHTML = `<thead><tr>
|
||
<th scope="col">File</th>
|
||
<th scope="col">Date</th>
|
||
<th scope="col">Duration</th>
|
||
<th scope="col">Size</th>
|
||
<th scope="col">Loudness</th>
|
||
<th scope="col"><span class="sr">Actions</span></th>
|
||
</tr></thead>`;
|
||
const dayTbody = document.createElement('tbody');
|
||
table.appendChild(dayTbody);
|
||
section.appendChild(table);
|
||
container.appendChild(section);
|
||
|
||
// File rows
|
||
dayFiles.forEach(f => {
|
||
const i = f._idx;
|
||
const ext = f.ext;
|
||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||
const isRec = !!f.recording;
|
||
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'data-row';
|
||
tr.id = 'row-'+i;
|
||
|
||
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
|
||
aria-label="Currently recording">
|
||
<span aria-hidden="true">●</span> REC</span>`;
|
||
|
||
tr.innerHTML = `
|
||
<td>
|
||
<span class="badge badge-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
|
||
</td>
|
||
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
|
||
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
|
||
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
|
||
<td id="wave-${i}">${canAnalyse ? '' :
|
||
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
|
||
<td>
|
||
<div class="actions">
|
||
<button id="pbtn-${i}"
|
||
aria-expanded="false"
|
||
aria-controls="prow-${i}"
|
||
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
||
<button id="cutbtn-${i}" class="cut"
|
||
aria-label="Cut ${esc(f.name)}"
|
||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
|
||
<button id="delbtn-${i}" class="del"
|
||
aria-label="Delete ${esc(f.name)}"
|
||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||
</div>
|
||
</td>`;
|
||
dayTbody.appendChild(tr);
|
||
|
||
const prow = document.createElement('tr');
|
||
prow.className = 'player-row';
|
||
prow.id = 'prow-'+i;
|
||
prow.hidden = true;
|
||
const durLabel = f.duration != null
|
||
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
|
||
: '';
|
||
prow.innerHTML = `<td colspan="6">
|
||
<audio id="aud-${i}" controls preload="none"
|
||
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
||
<div class="cut-panel">
|
||
<span class="cut-label">✂ Cut:</span>
|
||
<label class="cut-field">Start
|
||
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||
</label>
|
||
<label class="cut-field">End
|
||
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||
</label>
|
||
<button id="cut-dl-${i}" class="cut"
|
||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
|
||
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
|
||
</div>
|
||
</td>`;
|
||
dayTbody.appendChild(prow);
|
||
|
||
_attachFileRowHandlers(f, isRec, expanded, dayId);
|
||
});
|
||
|
||
// Day toggle handler
|
||
document.getElementById('daytgl-' + dayId).addEventListener('click', () => {
|
||
const nowExp = !dayExpanded.get(dayId);
|
||
dayExpanded.set(dayId, nowExp);
|
||
const tgl = document.getElementById('daytgl-' + dayId);
|
||
tgl.setAttribute('aria-expanded', nowExp);
|
||
tgl.querySelector('.day-arrow').textContent = nowExp ? '▼' : '▶';
|
||
headBar.classList.toggle('open', nowExp);
|
||
document.getElementById('daytbl-' + dayId).hidden = !nowExp;
|
||
if (!nowExp) {
|
||
dayFiles.forEach(f => closePlayer(f._idx));
|
||
document.getElementById('dayhl-' + dayId).hidden = true;
|
||
if (dayActiveId === dayId) {
|
||
dayActiveSections = [];
|
||
dayActiveSectionCursor = -1;
|
||
dayActiveId = null;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Highlights button handler
|
||
if (canHl) {
|
||
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
|
||
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||
dayHighlights(dayId, hlFiles);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
async function dayHighlights(dayId, analyzableFiles) {
|
||
const hlRow = document.getElementById('dayhl-' + dayId);
|
||
const contentEl = document.getElementById('dayhlc-' + dayId);
|
||
const btn = document.getElementById('dayhln-' + dayId);
|
||
|
||
hlRow.hidden = false;
|
||
const n = analyzableFiles.length;
|
||
if (btn) btn.disabled = true;
|
||
|
||
// Build progress UI
|
||
const progWrap = document.createElement('div');
|
||
progWrap.setAttribute('aria-live', 'polite'); progWrap.setAttribute('aria-busy', 'true');
|
||
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
|
||
const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progFill.style.width = '0%';
|
||
progBar.appendChild(progFill);
|
||
const progFile = document.createElement('div'); progFile.className = 'prog-file';
|
||
progWrap.appendChild(progBar); progWrap.appendChild(progFile);
|
||
contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
|
||
|
||
const threshold = document.getElementById('threshold-input').value || '0.05';
|
||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||
|
||
const results = [];
|
||
let nCached = 0, nLive = 0;
|
||
for (let i = 0; i < analyzableFiles.length; i++) {
|
||
const f = analyzableFiles[i];
|
||
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||
progFill.style.width = `${(i / n) * 100}%`;
|
||
try {
|
||
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
|
||
+ '&threshold=' + encodeURIComponent(threshold)
|
||
+ '&min_gap=' + encodeURIComponent(minGap));
|
||
const d = await r.json();
|
||
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
||
} catch(e) {}
|
||
}
|
||
progFill.style.width = '100%';
|
||
const doneExtra = nCached ? ` (${nCached} from cache)` : '';
|
||
progFile.textContent = `Done — ${n} file${n!==1?'s':''}${doneExtra}`;
|
||
|
||
if (!results.length) {
|
||
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
|
||
if (btn) btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Map files onto the day timeline using mtime as file-end, duration for start
|
||
const positioned = results.map(({ f, data }) => {
|
||
const fileEnd = f.mtime;
|
||
const fileDur = data.duration || f.duration || 0;
|
||
const fileStart = fileEnd - fileDur;
|
||
return { f, data, fileStart, fileEnd, fileDur };
|
||
}).filter(r => r.fileDur > 0);
|
||
|
||
if (!positioned.length) {
|
||
contentEl.innerHTML = '<div class="quiet">Could not determine file time positions.</div>';
|
||
if (btn) btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
const minT = Math.min(...positioned.map(r => r.fileStart));
|
||
const maxT = Math.max(...positioned.map(r => r.fileEnd));
|
||
const spanT = maxT - minT || 1;
|
||
const W = 800, H = 22;
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
|
||
const svg = document.createElementNS(ns, 'svg');
|
||
svg.setAttribute('class', 'wave day-timeline');
|
||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||
svg.setAttribute('preserveAspectRatio', 'none');
|
||
svg.setAttribute('role', 'img');
|
||
const totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
|
||
svg.setAttribute('aria-label', `Day activity: ${results.length} file${results.length!==1?'s':''}, ${totalSecs} loud section${totalSecs!==1?'s':''}`);
|
||
|
||
// Background track
|
||
const bgR = document.createElementNS(ns, 'rect');
|
||
bgR.setAttribute('x', 0); bgR.setAttribute('y', 7);
|
||
bgR.setAttribute('width', W); bgR.setAttribute('height', 8);
|
||
bgR.setAttribute('fill', '#1e2535');
|
||
svg.appendChild(bgR);
|
||
|
||
positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => {
|
||
const fx = ((fileStart - minT) / spanT) * W;
|
||
const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W);
|
||
|
||
// File span (dim blue)
|
||
const fr = document.createElementNS(ns, 'rect');
|
||
fr.setAttribute('x', fx); fr.setAttribute('y', 8);
|
||
fr.setAttribute('width', fw); fr.setAttribute('height', 6);
|
||
fr.setAttribute('fill', '#1e3a5f');
|
||
fr.setAttribute('aria-hidden', 'true');
|
||
svg.appendChild(fr);
|
||
|
||
// Loud sections (orange)
|
||
(data.sections || []).forEach(s => {
|
||
const sx = fx + (s.start / fileDur) * fw;
|
||
const sw = Math.max(1, ((s.end - s.start) / fileDur) * fw);
|
||
const sr = document.createElementNS(ns, 'rect');
|
||
sr.setAttribute('x', sx); sr.setAttribute('y', 4);
|
||
sr.setAttribute('width', sw); sr.setAttribute('height', 14);
|
||
sr.setAttribute('fill', '#f97316');
|
||
sr.setAttribute('rx', '1');
|
||
sr.setAttribute('aria-hidden', 'true');
|
||
svg.appendChild(sr);
|
||
});
|
||
});
|
||
|
||
const fmtHM = ts => {
|
||
const d = new Date(ts * 1000);
|
||
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
|
||
};
|
||
|
||
// Build cross-file section list for J/K navigation and chips
|
||
dayActiveSections = [];
|
||
positioned.forEach(({ f, data, fileStart }) => {
|
||
(data.sections || []).forEach(s => {
|
||
dayActiveSections.push({
|
||
fileIdx: f._idx,
|
||
filename: f.name,
|
||
start: s.start,
|
||
end: s.end,
|
||
absStart: fileStart + s.start,
|
||
});
|
||
});
|
||
});
|
||
dayActiveSections.sort((a, b) => a.absStart - b.absStart);
|
||
dayActiveSectionCursor = -1;
|
||
dayActiveId = dayId;
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'wbox';
|
||
box.style.marginBottom = '4px';
|
||
box.appendChild(svg);
|
||
|
||
const labels = document.createElement('div');
|
||
labels.className = 'day-tl-labels';
|
||
labels.innerHTML = `<span>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`;
|
||
box.appendChild(labels);
|
||
|
||
if (dayActiveSections.length) {
|
||
const MAX_DAY_CHIPS = 50;
|
||
if (dayActiveSections.length > MAX_DAY_CHIPS) {
|
||
const note = document.createElement('p');
|
||
note.className = 'quiet';
|
||
note.style.marginTop = '6px';
|
||
note.textContent = `${dayActiveSections.length} sections — use J / K to navigate`;
|
||
box.appendChild(note);
|
||
} else {
|
||
const chips = document.createElement('div');
|
||
chips.className = 'chips';
|
||
chips.setAttribute('role', 'group');
|
||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
|
||
dayActiveSections.forEach((sec, si) => {
|
||
const c = document.createElement('button');
|
||
c.className = 'chip';
|
||
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
|
||
const d = new Date(sec.absStart * 1000);
|
||
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
||
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
||
+ d.getSeconds().toString().padStart(2,'0');
|
||
c.textContent = hms;
|
||
c.addEventListener('click', () => jumpToDaySection(si));
|
||
chips.appendChild(c);
|
||
});
|
||
box.appendChild(chips);
|
||
}
|
||
}
|
||
|
||
const summary = document.createElement('div');
|
||
summary.className = 'quiet';
|
||
summary.style.marginTop = '4px';
|
||
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
|
||
box.appendChild(summary);
|
||
|
||
contentEl.innerHTML = '';
|
||
contentEl.appendChild(box);
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
function jumpToDaySection(si) {
|
||
if (si < 0 || si >= dayActiveSections.length) return;
|
||
dayActiveSectionCursor = si;
|
||
const { fileIdx, filename, start, end } = dayActiveSections[si];
|
||
|
||
// Close the previous player if switching to a different file
|
||
if (activePlayerIdx !== null && activePlayerIdx !== fileIdx) closePlayer(activePlayerIdx);
|
||
|
||
seekToSection(fileIdx, filename, start, end, null);
|
||
announce(`Day section ${si + 1} of ${dayActiveSections.length}: ${fmtDur(start)}–${fmtDur(end)} in ${filename}`);
|
||
}
|
||
|
||
function applyFilters() {
|
||
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
||
const fromD = document.getElementById('filter-from').value;
|
||
const toD = document.getElementById('filter-to').value;
|
||
const filtered = allFiles.filter(f => {
|
||
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||
if (toD && f.date > toD + ' 23:59:59') return false;
|
||
return true;
|
||
});
|
||
renderFiles(filtered);
|
||
}
|
||
|
||
async function load() {
|
||
const refreshBtn = document.getElementById('refresh-btn');
|
||
refreshBtn.disabled = true;
|
||
document.getElementById('subtitle').textContent = 'Loading…';
|
||
|
||
let files;
|
||
try {
|
||
files = await (await fetch('/api/files')).json();
|
||
} catch(e) {
|
||
document.getElementById('subtitle').textContent = 'Error loading files';
|
||
refreshBtn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
allFiles = files.map((f, i) => ({...f, _idx: i}));
|
||
updateStorage();
|
||
applyFilters();
|
||
refreshBtn.disabled = false;
|
||
}
|
||
|
||
// Poll recording status every 5 s to update REC badges
|
||
async function pollStatus() {
|
||
try {
|
||
const s = await (await fetch('/api/status')).json();
|
||
const active = new Set(s.active || []);
|
||
recMap.forEach((filename, idx) => {
|
||
const badge = document.getElementById('rec-'+idx);
|
||
if (badge) badge.hidden = !active.has(filename);
|
||
});
|
||
} catch(e) {}
|
||
}
|
||
|
||
document.getElementById('refresh-btn').addEventListener('click', load);
|
||
|
||
document.getElementById('filter-name').addEventListener('input', applyFilters);
|
||
document.getElementById('filter-from').addEventListener('change', applyFilters);
|
||
document.getElementById('filter-to').addEventListener('change', applyFilters);
|
||
document.getElementById('filter-clear').addEventListener('click', () => {
|
||
document.getElementById('filter-name').value = '';
|
||
document.getElementById('filter-from').value = '';
|
||
document.getElementById('filter-to').value = '';
|
||
applyFilters();
|
||
});
|
||
|
||
// Seed threshold and min_gap from server config, then start
|
||
fetch('/api/config').then(r => r.json()).then(cfg => {
|
||
if (cfg.threshold != null)
|
||
document.getElementById('threshold-input').value = cfg.threshold;
|
||
if (cfg.min_gap != null)
|
||
document.getElementById('min-gap-input').value = cfg.min_gap;
|
||
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||
</script>
|
||
</body>
|
||
</html> |