Files
ISR/webui.html
T
admin 8e496ec2c4 perf: faster page loads, live-recording playback and seeking fixes
Server (web.py):
- /api/analyze no longer returns the full per-window RMS array (~45x
  larger than the rms_display the UI actually renders); old caches are
  stripped on read
- /api/files reads only the first 256 bytes of each analysis cache to
  get threshold/min_gap instead of parsing the whole JSON
- durations cached by (mtime, size) instead of re-opening every audio
  header per request; stat() race with deleted files guarded
- /api/storage no longer walks the recordings tree (used bytes now
  computed client-side from the file list)
- HTTP/1.1 keep-alive enabled; short writes force-close the connection;
  client-disconnect tracebacks from aborted seeks silenced
- all file copies bounded by the advertised Content-Length so files
  growing during a response cannot desync the connection

Live recording playback:
- /stream/ patches in-progress WAV headers to the current file size so
  browsers show real duration and can seek (on-disk header says 0
  frames until the recorder closes the file)
- active files served with Cache-Control: no-store
- reopening the player for a recording file reloads the source to pick
  up newly captured audio

UI loading:
- analyses lazy-load only for expanded day groups; collapsed days defer
  fetching until opened, and auto-load only when cached parameters
  match the current controls (no surprise mass recompute)
- client-side analysis cache shared by file rows and day highlights, so
  re-renders and filters never refetch
- filename filter debounced (200 ms)
- file list auto-refreshes when the active recording set changes,
  unless audio is playing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:29:13 +02:00

1021 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">&#8635; 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 01 (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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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');
} else if (!document.getElementById('rec-'+idx)?.hidden) {
// Still recording: re-fetch so duration and seek range cover the audio
// captured since the source was last loaded
audio.load();
}
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)}`);
}
}
// filename|threshold|gap -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has
const analysisCache = new Map();
async function fetchAnalysis(filename, threshold, minGap, force = false) {
const key = `${filename}|${threshold}|${minGap}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key);
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&threshold='+encodeURIComponent(threshold)
+'&min_gap='+encodeURIComponent(minGap));
const d = await r.json();
if (!d.error) analysisCache.set(key, d);
return d;
}
async function analyse(idx, filename, cell, btn, force = false) {
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 d = await fetchAnalysis(filename, threshold, minGap, force);
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, true);
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);
for (const k of [...analysisCache.keys()])
if (k.startsWith(filename + '|')) analysisCache.delete(k);
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');
const used = allFiles.reduce((a, f) => a + f.size, 0);
let txt = fmtSize(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) {}
}
// Does a server-side cached analysis match the current control values?
// Auto-loading on a mismatch would silently recompute every file.
function cachedParamsMatch(ca) {
return ca != null
&& Number(ca.threshold) === parseFloat(document.getElementById('threshold-input').value)
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value);
}
// Run the deferred analyses of a freshly expanded day
function autoloadDayAnalyses(dayId) {
document.querySelectorAll('#daytbl-' + dayId + ' td[data-autoload="1"]').forEach(cell => {
cell.removeAttribute('data-autoload');
const btn = cell.querySelector('button') || document.createElement('button');
analyse(Number(cell.dataset.idx), cell.dataset.fname, cell, btn);
});
}
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 (cachedParamsMatch(f.cached_analysis)) {
if (expanded) {
abtn.textContent = 'Re-analyse';
analyse(i, f.name, cell, abtn);
} else {
// Collapsed day: defer the fetch until the day is opened
cell.setAttribute('data-autoload', '1');
cell.dataset.idx = i;
cell.dataset.fname = f.name;
abtn.textContent = 'Analyse';
abtn.onclick = () => analyse(i, f.name, cell, abtn);
cell.appendChild(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) autoloadDayAnalyses(dayId);
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 d = await fetchAnalysis(f.name, threshold, minGap);
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
let lastActiveKey = null;
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);
});
// Active set changed (recording started/stopped or rolled over to a new
// split file): refresh the list so new files appear — but never yank the
// DOM out from under an in-progress playback.
const key = [...active].sort().join('|');
if (lastActiveKey === null) { lastActiveKey = key; return; }
if (key !== lastActiveKey) {
const playing = [...document.querySelectorAll('audio')].some(a => !a.paused && !a.ended);
if (!playing) { lastActiveKey = key; load(); }
}
} catch(e) {}
}
document.getElementById('refresh-btn').addEventListener('click', load);
let filterDebounce;
document.getElementById('filter-name').addEventListener('input', () => {
clearTimeout(filterDebounce);
filterDebounce = setTimeout(applyFilters, 200);
});
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>