Files
ISR/webui.html
T
admin 2b0403d05d feat: wall-clock clip labels, collapsible day Highlights with analysed marker
- Clip bar label is now the wall-clock time of occurrence plus queue
  position ("03:46:20 to 03:46:22 (73 / 187)"); filename and score moved
  to the hover tooltip. Works for both per-file and day queues via an
  absStart epoch on every queue entry, derived from the filename clock
  (listing date), falling back to in-file offsets for non-standard names.
- Day Highlights button toggles the panel; re-expanding reuses the
  already-built results (re-armed J/K queue from dayHlSections) and only
  recomputes when margin/gap/min-duration changed. A "analysed" suffix
  marks days where every file has a cached analysis for the current
  params; fetchAnalysis keeps f.cached_analysis fresh client-side.
- Day timeline now positions files by the filename clock instead of
  mtime-duration, and the three axis labels (span start / midpoint /
  end) carry explanatory tooltips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:28:23 +02:00

1219 lines
52 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}
button.day-hl .day-arrow{font-size:9px}
.day-hl-status{opacity:.75;font-weight:400}
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}
/* clip player bar */
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf);
border-top:1px solid var(--brd);padding:8px 28px;display:flex;align-items:center;
gap:10px;flex-wrap:wrap}
/* display:flex above beats the UA's [hidden]{display:none} — restate it, or
the bar is visible from page load and the close button looks dead */
#clip-bar[hidden]{display:none}
#clip-bar audio{flex:1 1 240px;min-width:180px;height:32px}
#clip-label{font-size:12px;color:var(--muted);font-family:ui-monospace,monospace;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%}
#clip-auto-label,#clip-hl-label,#clip-top-label{font-size:12px;color:var(--muted);
display:flex;align-items:center;gap:4px;white-space:nowrap;cursor:pointer}
#clip-top{width:52px}
body.clip-open{padding-bottom:70px}
</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="margin-input">Loudness margin:</label>
<input type="number" id="margin-input" min="1" max="60" step="1" value="12"
aria-describedby="margin-hint">
<span id="margin-hint" class="controls-hint">dB above background noise — sections that rise this far above the rolling noise floor 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>
<label for="min-duration-input" style="margin-left:16px">Min duration:</label>
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" value="0.5"
aria-describedby="min-duration-hint">
<span id="min-duration-hint" class="controls-hint">seconds — ignore loud sections shorter 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>
<div id="clip-bar" hidden role="region" aria-label="Section clip player">
<button id="clip-prev" aria-label="Previous section (J)">Prev</button>
<button id="clip-next" aria-label="Next section (K)">Next</button>
<span id="clip-label"></span>
<audio id="clip-audio" controls preload="auto" aria-label="Section clip playback"></audio>
<label id="clip-auto-label"><input type="checkbox" id="clip-auto" checked> Auto-advance</label>
<label id="clip-hl-label" title="J/K, Prev/Next and auto-advance skip everything outside the top sections"><input type="checkbox" id="clip-hl-only"> Highlights only</label>
<label id="clip-top-label" title="How many top-scored sections count as highlights">Top <input type="number" id="clip-top" min="1" step="1" value="50" aria-label="Number of top-scored sections treated as highlights"></label>
<button id="clip-context" title="Open the full recording at this position (O)" aria-label="Open in file (O)">Open in file</button>
<button id="clip-close" aria-label="Close clip player">&times;</button>
</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');
// Epoch seconds -> local wall-clock "HH:MM:SS"
const fmtClock = ts => {
const d = new Date(ts * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
};
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
// the filename server-side) -> epoch seconds, or null for unparseable values
const fileStartEpoch = date => {
const t = Date.parse(String(date).replace(' ', 'T'));
return isNaN(t) ? null : t / 1000;
};
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 list (populated by the day Highlights button)
let dayActiveSections = [];
let dayActiveId = null;
// dayId -> section list of an already-built highlights panel, so collapsing
// and re-expanding it re-arms J/K without recomputing
const dayHlSections = new Map();
// Current analysis params as one string; a highlights panel built with other
// values is stale and gets recomputed on the next expand
const hlParams = () =>
['margin-input', 'min-gap-input', 'min-duration-input']
.map(id => document.getElementById(id).value).join('|');
function setHlExpanded(dayId, exp) {
const btn = document.getElementById('dayhln-' + dayId);
if (!btn) return;
btn.setAttribute('aria-expanded', exp);
const arrow = btn.querySelector('.day-arrow');
if (arrow) arrow.textContent = exp ? '▾' : '▸';
}
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)}`);
}
}
// --- clip player -----------------------------------------------------------
// Sections play as small server-rendered WAV clips (/api/clip) in the bottom
// bar instead of seeking the full recording, which is slow for big FLACs.
// clipQueue holds the active review list (one file's sections, or a whole
// day's); J/K and the Prev/Next buttons step through it, U/I (or the
// "Highlights only" toggle) restrict stepping to the top-N entries by score.
let clipQueue = [];
let clipCursor = -1;
function playClip(i) {
if (i < 0 || i >= clipQueue.length) return;
clipCursor = i;
const c = clipQueue[i];
const cs = Math.max(0, c.start - getPreroll());
const ce = c.end + 1.5; // small tail after the section
const a = document.getElementById('clip-audio');
a.src = '/api/clip?file=' + encodeURIComponent(c.filename)
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
a.play().catch(() => {});
// Label = wall-clock time of occurrence (absStart from the filename clock);
// falls back to in-file offsets for non-standard filenames.
const when = c.absStart != null
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
const label = document.getElementById('clip-label');
label.textContent = `${when} (${i + 1} / ${clipQueue.length})`;
label.title = `${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`
+ (c.score != null ? ` · +${Math.round(c.score)} dB` : '');
document.getElementById('clip-bar').hidden = false;
document.body.classList.add('clip-open');
setCutFields(c.fileIdx, c.start, c.end);
announce(`Clip ${i + 1} of ${clipQueue.length}: ${when}`);
}
function hideClipBar() {
const a = document.getElementById('clip-audio');
a.pause();
a.removeAttribute('src');
document.getElementById('clip-bar').hidden = true;
document.body.classList.remove('clip-open');
clipQueue = [];
clipCursor = -1;
}
function playFileSection(idx, filename, si) {
const secs = sectionMap.get(idx) || [];
const f = allFiles.find(f => f._idx === idx);
const epoch = f ? fileStartEpoch(f.date) : null;
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null}));
playClip(si);
}
// Highlights = the top-N entries of a section list by score (N from the
// clip-bar "Top" input). Returns the indices into the original list.
function topScoreSet(secs) {
const n = Math.max(1, parseInt(document.getElementById('clip-top').value, 10) || 50);
return new Set(secs.map((s, i) => ({i, score: s.score || 0}))
.sort((a, b) => b.score - a.score)
.slice(0, n)
.map(r => r.i));
}
function highlightsOnly() {
return document.getElementById('clip-hl-only').checked;
}
// Step the clip queue by dir (±1); with hlOnly, skip non-highlight entries.
function stepClip(dir, hlOnly) {
if (!clipQueue.length) return;
const hs = hlOnly ? topScoreSet(clipQueue) : null;
const word = hlOnly ? 'highlights' : 'sections';
for (let i = clipCursor + dir; i >= 0 && i < clipQueue.length; i += dir) {
if (!hs || hs.has(i)) { playClip(i); return; }
}
announce(dir < 0 ? `Beginning of ${word}` : `End of ${word}`);
}
document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, highlightsOnly()));
document.getElementById('clip-next').addEventListener('click', () => stepClip(1, highlightsOnly()));
document.getElementById('clip-close').addEventListener('click', hideClipBar);
document.getElementById('clip-audio').addEventListener('ended', () => {
if (document.getElementById('clip-auto').checked)
stepClip(1, highlightsOnly());
});
// "Open in file": switch from the current clip to the full recording at the
// same position. Bound to the clip-bar button and the O key.
function openClipInFile() {
const c = clipQueue[clipCursor];
if (!c) return;
document.getElementById('clip-audio').pause();
const row = document.getElementById('row-' + c.fileIdx);
if (row) {
const tbl = row.closest('table');
if (tbl && tbl.hidden) // file's day is collapsed: expand it
document.getElementById('daytgl-' + tbl.id.slice('daytbl-'.length))?.click();
row.scrollIntoView({block: 'center'});
}
seekToSection(c.fileIdx, c.filename, c.start, c.end, null);
}
document.getElementById('clip-context').addEventListener('click', openClipInFile);
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has
const analysisCache = new Map();
async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
const key = `${filename}|${margin}|${minGap}|${minDur}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key);
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&margin='+encodeURIComponent(margin)
+'&min_gap='+encodeURIComponent(minGap)
+'&min_duration='+encodeURIComponent(minDur));
const d = await r.json();
if (!d.error) {
analysisCache.set(key, d);
// Keep the listing's cache info current so re-renders (filtering) still
// know the file is analysed without refetching /api/files
const f = allFiles.find(f => f.name === filename);
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
}
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 margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2';
const minDur = document.getElementById('min-duration-input').value || '0.5';
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, margin, minGap, minDur, 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 = `margin: ${margin} dB · gap: ${minGap}s · min: ${minDur}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, U/I for highlights only');
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 = 'Play this section (or use J/K keys)';
c.textContent = `${fmtDur(s.start)} ${fmtDur(s.end)}`
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
c.addEventListener('click', () => playFileSection(idx, filename, 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/K = previous/next section, U/I = previous/next highlight (top-N by score),
// O = open the current clip in the full file.
// With "Highlights only" checked, J/K behave like U/I.
// Only when focus is not in an input.
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase();
if (key !== 'j' && key !== 'k' && key !== 'u' && key !== 'i' && key !== 'o') return;
e.preventDefault();
if (key === 'o') { openClipInFile(); return; }
const dir = (key === 'j' || key === 'u') ? -1 : 1;
const hlOnly = key === 'u' || key === 'i' || highlightsOnly();
// Clip queue navigation (a chip was clicked or day highlights are loaded)
if (clipQueue.length) {
stepClip(dir, hlOnly);
return;
}
// Per-file in-player navigation (full-file listening, no clip queue)
if (activePlayerIdx === null) return;
const all = sectionMap.get(activePlayerIdx) || [];
if (!all.length) return;
const audio = document.getElementById('aud-'+activePlayerIdx);
if (!audio) return;
const hs = hlOnly ? topScoreSet(all) : null;
const sections = all.filter((s, i) => !hs || hs.has(i));
if (!sections.length) return;
const preroll = getPreroll();
const cur = audio.currentTime;
const word = hlOnly ? 'Highlight' : 'Section';
const jumpTo = (s, i) => {
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`${word} ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
};
if (dir < 0) {
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].start < cur - 1) { jumpTo(sections[i], i); return; }
}
announce(`Beginning of ${word.toLowerCase()}s`);
} else {
for (let i = 0; i < sections.length; i++) {
if (sections[i].start > cur + preroll) { jumpTo(sections[i], i); return; }
}
announce(`End of ${word.toLowerCase()}s`);
}
});
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.margin) === parseFloat(document.getElementById('margin-input').value)
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value)
&& Number(ca.min_duration) === parseFloat(document.getElementById('min-duration-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 hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
const canHl = hlFiles.length > 0;
// Every analysable file already has a cached analysis for the current
// params -> the day's highlights are available without recomputing
const analysed = canHl && hlFiles.every(f => cachedParamsMatch(f.cached_analysis));
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-expanded="false" aria-controls="dayhl-${dayId}"
aria-label="Day highlights for ${esc(day)}">
<span class="day-arrow" aria-hidden="true">▸</span> Highlights<span
class="day-hl-status" id="dayhls-${dayId}"${analysed ? '' : ' hidden'}> · analysed</span></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;
setHlExpanded(dayId, false);
if (dayActiveId === dayId) {
dayActiveSections = [];
dayActiveId = null;
hideClipBar();
}
}
});
// Highlights button: expand/collapse the panel; only (re)compute when it
// has not been built yet this session or the analysis params changed
if (canHl) {
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
const hlRow = document.getElementById('dayhl-' + dayId);
if (!hlRow.hidden) { // collapse, keep the panel
hlRow.hidden = true;
setHlExpanded(dayId, false);
return;
}
if (hlRow.dataset.loaded === hlParams()) { // re-open and re-arm J/K
hlRow.hidden = false;
setHlExpanded(dayId, true);
dayActiveSections = dayHlSections.get(dayId) || [];
dayActiveId = dayId;
clipQueue = dayActiveSections;
clipCursor = -1;
return;
}
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;
setHlExpanded(dayId, true);
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 margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2';
const minDur = document.getElementById('min-duration-input').value || '0.5';
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, margin, minGap, minDur);
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. The filename is the clock: f.date is the
// recording start parsed server-side; mtime (≈ file end) is only a fallback
// for non-standard names.
const positioned = results.map(({ f, data }) => {
const fileDur = data.duration || f.duration || 0;
const startEpoch = fileStartEpoch(f.date);
const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur;
return { f, data, fileStart, fileEnd: fileStart + fileDur, 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,
score: s.score,
absStart: fileStart + s.start,
});
});
});
dayActiveSections.sort((a, b) => a.absStart - b.absStart);
dayActiveId = dayId;
// Arm the clip queue so J/K steps through the day immediately
clipQueue = dayActiveSections;
clipCursor = -1;
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 title="First recording starts">${esc(fmtHM(minT))}</span>`
+ `<span title="Timeline midpoint">${esc(fmtHM((minT+maxT)/2))}</span>`
+ `<span title="Last recording ends">${esc(fmtHM(maxT))}</span>`;
box.appendChild(labels);
if (dayActiveSections.length) {
const MAX_DAY_CHIPS = 50;
// When there are too many sections to show them all, show the ones most
// worth reviewing: the top MAX_DAY_CHIPS by score, loudest first.
let chipList = dayActiveSections.map((sec, si) => ({sec, si}));
const truncated = chipList.length > MAX_DAY_CHIPS;
if (truncated) {
chipList = chipList
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
.slice(0, MAX_DAY_CHIPS);
const note = document.createElement('p');
note.className = 'quiet';
note.style.marginTop = '6px';
note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order, U / I through highlights only`;
box.appendChild(note);
}
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, U/I for highlights only');
chipList.forEach(({sec, si}) => {
const c = document.createElement('button');
c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
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);
hlRow.dataset.loaded = hlParams();
dayHlSections.set(dayId, dayActiveSections);
// Every file is now cached with the current params
if (results.length === n)
document.getElementById('dayhls-' + dayId)?.removeAttribute('hidden');
if (btn) btn.disabled = false;
}
function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return;
clipQueue = dayActiveSections;
playClip(si);
}
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 margin and min_gap from server config, then start
fetch('/api/config').then(r => r.json()).then(cfg => {
if (cfg.margin != null)
document.getElementById('margin-input').value = cfg.margin;
if (cfg.min_gap != null)
document.getElementById('min-gap-input').value = cfg.min_gap;
if (cfg.min_duration != null)
document.getElementById('min-duration-input').value = cfg.min_duration;
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
</script>
</body>
</html>