Files
ISR/webui.html
T
adminandClaude Opus 5 0e7ea82da6 refactor: de-duplicate webui.html — shared ranking step, one param reader, one day-arm
No behaviour change. Targeted extractions only: the globals-heavy state
machine is left alone deliberately, since its interactions encode
screen-reader semantics with no JS test harness to catch a regression.

* The U/I loudness walk was implemented twice — once in stepClip's
  by-score branch, once in the keydown handler's in-player branch —
  including the anchor resolution, the bounds check and both boundary
  announcements. Extracted nextInOrder(order, pos, dir, jump). Verified
  behaviourally identical to both originals by exhaustive comparison
  over every (length, cursor, direction, shift) combination.

* The five coupled globals that arm a day's queue were set in two
  places (fresh build, re-open of a kept panel). Now armDayQueue().

* The three analysis inputs were read in three places, two of them with
  hardcoded fallbacks '12'/'2'/'0.5' — duplicating web.py's constants
  and the value= attributes, so the same three numbers lived in four
  client-side places. analysisParams() is now the only reader and the
  fallbacks are gone: an empty input sends an empty param, which
  _qs_float() resolves to the server default. hlParams() is its values
  joined.

* recMap (idx -> filename) duplicated allFiles; dropped, with
  fileNameByIdx() for the one label that needed it. pollStatus iterates
  allFiles and lets a missing badge element mark an unrendered row.

* analyse()'s btn is optional, so autoloadDayAnalyses stops fabricating
  a detached button just to have something to disable; the error path
  still produces a retry button either way.

* Unused dayId parameter on _attachFileRowHandlers; stale "waveform"
  CSS comment; vestigial .chip / button.chip split (all chips are
  buttons).

Verified: getElementById cross-check against declared and dynamic ids
(no unresolved), page serves and parses with every required id present,
pytest 78 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:15:49 +02:00

1243 lines
54 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}
/* analysis result box + section chips */
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chips[hidden]{display:none}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace;cursor:pointer}
.chip:hover{background:#6c1f08;border-color:#9a3412}
.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}
/* 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-adv{display:flex;align-items:center;gap:10px}
#clip-adv label{font-size:12px;color:var(--muted);
display:flex;align-items:center;gap:4px;white-space:nowrap;cursor:pointer}
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>
<span id="clip-adv" role="radiogroup" aria-label="Auto-advance mode">
<label><input type="radio" name="clip-adv" value="off"> Don't auto-advance</label>
<label><input type="radio" name="clip-adv" value="all" checked> Auto-advance</label>
<label title="When a clip ends, play the next-loudest section instead of the next in time"><input type="radio" name="clip-adv" value="hl"> Auto-advance highlights only</label>
</span>
<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);
}
// Row index for a filename, resolved at use time: _idx values are reassigned
// positionally on every load(), and pollStatus() reloads the list whenever a
// recording starts/stops/rolls over — a stored index would point one row off
// after that. Null when the file is gone from the current listing.
const fileIdxByName = name => allFiles.find(f => f.name === name)?._idx ?? null;
const fileNameByIdx = idx => allFiles.find(f => f._idx === idx)?.name ?? '';
// 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();
// The three analysis controls, read straight from the DOM. Deliberately no
// hardcoded fallbacks: the inputs are seeded from /api/config at startup, so
// web.py's MARGIN_DB / MIN_GAP_SECONDS / MIN_DURATION_SECONDS stay the single
// source of truth. An empty input sends an empty param, which _qs_float()
// falls back to the server default for.
const analysisParams = () => ({
margin: document.getElementById('margin-input').value,
minGap: document.getElementById('min-gap-input').value,
minDur: document.getElementById('min-duration-input').value,
});
// Same values as one string; a highlights panel built with other values is
// stale and gets recomputed on the next expand
const hlParams = () => Object.values(analysisParams()).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 '+fileNameByIdx(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 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) {
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);
}
// --- 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 in time order, U/I
// step through the loudest-first ranking. The auto-advance radio picks what
// plays when a clip ends: nothing, the next in time, or the next by loudness.
//
// Two cursors, deliberately independent. clipCursor is the time-order position
// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness
// walk's own position in scoreOrder(): only U/I (and "highlights only" auto-
// advance) move it; J/K detours to review nearby clips leave it alone, so
// coming back to U/I resumes the ranking exactly where you left it. An explicit
// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there.
let clipQueue = [];
let clipCursor = -1;
let scoreCursor = -1;
// byScore: this play is part of a loudness walk (U/I or "highlights only"
// auto-advance), so the position shown is scoreCursor — the rank in the
// loudest-first ranking, labelled "by loudness" — not the time-order index.
function playClip(i, byScore) {
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. The visible
// label and the screen-reader announcement use one identical string:
// "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" — the count is the
// time-order index "by time" (J/K) or the loudness rank "by loudness" (U/I).
const when = c.absStart != null
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
const score = c.score != null ? ` · +${Math.round(c.score)} dB` : '';
const pos = byScore ? scoreCursor : i;
const dim = byScore ? 'by loudness' : 'by time';
const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`;
const label = document.getElementById('clip-label');
label.textContent = text;
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
document.getElementById('clip-bar').hidden = false;
document.body.classList.add('clip-open');
const rowIdx = fileIdxByName(c.filename);
if (rowIdx != null) setCutFields(rowIdx, c.start, c.end);
announce(text);
}
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;
scoreCursor = -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 => ({filename, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null}));
scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si);
}
// Highlight order = section indices sorted loudest-first. U/I (and
// auto-advance in "highlights only" mode) walk this ranking, so a review
// runs from the most to the least interesting event until the user stops —
// no top-N cutoff.
function scoreOrder(secs) {
return secs.map((s, i) => ({i, score: s.score || 0}))
.sort((a, b) => b.score - a.score)
.map(r => r.i);
}
// Step a position within an ordering: pos + dir, or straight to the extreme in
// that direction when jump. Returns null when the step runs off either end.
// pos === -1 means "not anchored yet" — start at the first entry going forward,
// or off the front going backward so the caller announces the boundary.
// Shared by both U/I walks (clip queue and in-player); they differ only in what
// the anchor is and what "play" means, and drifting apart is exactly how the
// two would stop agreeing.
function nextInOrder(order, pos, dir, jump) {
const next = jump ? (dir > 0 ? order.length - 1 : 0)
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
return (next >= 0 && next < order.length) ? next : null;
}
const advanceMode = () =>
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
// Step the clip queue by dir (±1): in time order, or with byScore down/up
// the loudest-first ranking (next = next quieter). With jump=true, go straight
// to the extreme in that direction (first/last section, or loudest/quietest).
function stepClip(dir, byScore, jump) {
if (!clipQueue.length) return;
if (byScore) {
// Walk scoreCursor, not the shared clipCursor: a J/K detour never moved
// it, so we resume the ranking where we left off. scoreCursor === -1 means
// the walk hasn't started since the queue was armed or an explicit jump —
// anchor on the section currently selected (clipCursor), else the loudest.
const order = scoreOrder(clipQueue);
const pos = scoreCursor !== -1 ? scoreCursor
: clipCursor >= 0 ? order.indexOf(clipCursor)
: -1;
const next = nextInOrder(order, pos, dir, jump);
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
scoreCursor = next;
playClip(order[next], true);
return;
}
// J/K (time order) deliberately does NOT touch scoreCursor.
const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir;
if (i >= 0 && i < clipQueue.length) playClip(i);
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
}
document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, false));
document.getElementById('clip-next').addEventListener('click', () => stepClip(1, false));
document.getElementById('clip-close').addEventListener('click', hideClipBar);
document.getElementById('clip-audio').addEventListener('ended', () => {
const mode = advanceMode();
if (mode !== 'off') stepClip(1, mode === 'hl');
});
// "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;
const idx = fileIdxByName(c.filename);
if (idx == null) return;
document.getElementById('clip-audio').pause();
const row = document.getElementById('row-' + idx);
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(idx, c.filename, c.start, c.end);
}
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;
}
// btn is optional: the deferred-autoload path has one in the cell already,
// but a caller with no button still gets one back on the error path so the
// analysis stays retryable.
async function analyse(idx, filename, cell, btn, force = false) {
if (btn) { btn.disabled = true; btn.textContent = '…'; }
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
const {margin, minGap, minDur} = analysisParams();
const restoreBtn = () => {
const b = btn || document.createElement('button');
b.textContent = 'Analyse';
b.disabled = false;
b.setAttribute('aria-label', `Analyse loudness of ${filename}`);
b.onclick = () => analyse(idx, filename, cell, b);
if (!cell.contains(b)) cell.appendChild(b);
};
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';
const nSec = (d.sections || []).length;
const meta = document.createElement('div'); meta.className='analysis-meta';
meta.textContent = `${nSec} loud section${nSec!==1?'s':''}`
+ ` · 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');
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 in time order, U/I = up/down the loudest-first
// ranking, O = open the current clip in the full file. Holding Shift jumps to
// the extreme in that direction: Shift+J/K = first/last section in time,
// Shift+U/I = loudest/quietest. 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 byScore = key === 'u' || key === 'i';
const jump = e.shiftKey;
// Clip queue navigation (a chip was clicked or day highlights are loaded)
if (clipQueue.length) {
stepClip(dir, byScore, jump);
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 preroll = getPreroll();
const cur = audio.currentTime;
const jumpTo = (s, i, n, word) => {
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`${word} ${i + 1} of ${n}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
};
if (byScore) {
// U/I walk the ranking; the anchor is the section the playhead sits in
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
// Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I).
const order = scoreOrder(all);
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5);
const pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
const next = nextInOrder(order, pos, dir, jump);
if (next === null) {
announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
return;
}
jumpTo(all[order[next]], next, order.length, 'Highlight');
return;
}
// Shift+J/K jump to the first/last section regardless of the playhead.
if (jump) {
const i = dir < 0 ? 0 : all.length - 1;
jumpTo(all[i], i, all.length, 'Section');
return;
}
if (dir < 0) {
for (let i = all.length - 1; i >= 0; i--) {
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
}
announce('Beginning of sections');
} else {
for (let i = 0; i < all.length; i++) {
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
}
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);
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');
analyse(Number(cell.dataset.idx), cell.dataset.fname, cell, cell.querySelector('button'));
});
}
function _attachFileRowHandlers(f, isRec, expanded) {
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));
}
}
function renderFiles(files) {
const container = document.getElementById('tbody');
container.innerHTML = '';
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);
// Local date, not toISOString() — f.date is the recording start in local
// time, so a UTC "today" leaves the current day collapsed for part of every
// day in any non-UTC timezone (00:00–02:00 in UTC+2, all evening in UTC−5).
const now = new Date();
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
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)}${analysed ? ', analysed' : ''}">
<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);
});
// 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);
armDayQueue(dayId, dayHlSections.get(dayId) || []);
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, minGap, minDur} = analysisParams();
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 totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
// Build cross-file section list for J/K navigation and chips
const sections = [];
positioned.forEach(({ f, data, fileStart }) => {
(data.sections || []).forEach(s => {
sections.push({
filename: f.name,
start: s.start,
end: s.end,
score: s.score,
absStart: fileStart + s.start,
});
});
});
sections.sort((a, b) => a.absStart - b.absStart);
// Arm the clip queue so J/K steps through the day immediately
armDayQueue(dayId, sections);
const box = document.createElement('div');
box.className = 'wbox';
box.style.marginBottom = '4px';
// Totals first, then the key hint, then the chips: linear reading order
const summary = document.createElement('div');
summary.className = 'quiet';
summary.style.marginTop = '0';
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
box.appendChild(summary);
if (dayActiveSections.length) {
const note = document.createElement('p');
note.className = 'quiet';
note.style.marginTop = '6px';
note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first). Hold Shift to jump to the first / last section (Shift + J / K) or the loudest / quietest (Shift + U / I)';
box.appendChild(note);
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 chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections');
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);
});
// A long chip list is a wall of buttons: collapse it behind a toggle
if (chipList.length > 12) {
chips.hidden = true;
const tog = document.createElement('button');
tog.style.marginTop = '8px';
tog.setAttribute('aria-expanded', 'false');
const arrow = document.createElement('span');
arrow.className = 'day-arrow';
arrow.setAttribute('aria-hidden', 'true');
// the separator space lives inside the aria-hidden arrow: a leading
// space in the text node shows up as an indent in screen readers
arrow.textContent = '▸ ';
tog.appendChild(arrow);
tog.appendChild(document.createTextNode(truncated
? `Top ${chipList.length} sections by loudness`
: `${chipList.length} sections`));
tog.addEventListener('click', () => {
const exp = chips.hidden;
chips.hidden = !exp;
tog.setAttribute('aria-expanded', exp);
arrow.textContent = exp ? '▾ ' : '▸ ';
});
box.appendChild(tog);
}
box.appendChild(chips);
}
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');
// aria-label overrides the button content, so the analysed marker has to
// be mirrored into it or screen readers never hear it
if (btn && !/, analysed$/.test(btn.getAttribute('aria-label') || ''))
btn.setAttribute('aria-label', btn.getAttribute('aria-label') + ', analysed');
}
if (btn) btn.disabled = false;
}
// Point the clip queue at a day's cross-file section list, from either the
// fresh-build path or the re-open-a-kept-panel path. Five coupled globals set
// in two places is how the stale-_idx and diverging-label bugs happened.
// Both cursors reset: nothing is selected yet, and the next U/I must re-anchor
// here rather than resume a walk through the previous queue.
function armDayQueue(dayId, sections) {
dayActiveSections = sections;
dayActiveId = dayId;
clipQueue = sections;
clipCursor = -1;
scoreCursor = -1;
}
function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return;
clipQueue = dayActiveSections;
scoreCursor = -1; // explicit jump: next U/I re-anchors here
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 || []);
// Rows not currently rendered (filtered out, collapsed day) have no
// badge element, so getElementById skips them.
allFiles.forEach(f => {
const badge = document.getElementById('rec-'+f._idx);
if (badge) badge.hidden = !active.has(f.name);
});
// 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>