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>
This commit is contained in:
2026-06-12 08:28:23 +02:00
parent 9f1a6ff711
commit 2b0403d05d
3 changed files with 98 additions and 24 deletions
+94 -20
View File
@@ -115,6 +115,8 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
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}
@@ -205,6 +207,17 @@ const fmtSize = b => {
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');
@@ -235,6 +248,23 @@ 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();
@@ -368,13 +398,19 @@ function playClip(i) {
a.src = '/api/clip?file=' + encodeURIComponent(c.filename)
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
a.play().catch(() => {});
document.getElementById('clip-label').textContent =
`${i + 1}/${clipQueue.length} · ${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`
// 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}: ${fmtDur(c.start)} to ${fmtDur(c.end)} in ${c.filename}`);
announce(`Clip ${i + 1} of ${clipQueue.length}: ${when}`);
}
function hideClipBar() {
@@ -388,8 +424,11 @@ function hideClipBar() {
}
function playFileSection(idx, filename, si) {
const secs = sectionMap.get(idx) || [];
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score}));
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);
}
@@ -454,7 +493,13 @@ async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
+'&min_gap='+encodeURIComponent(minGap)
+'&min_duration='+encodeURIComponent(minDur));
const d = await r.json();
if (!d.error) analysisCache.set(key, d);
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;
}
@@ -712,7 +757,11 @@ function renderFiles(files) {
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
const canHl = dayFiles.some(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
const 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' : ''}`;
@@ -737,7 +786,10 @@ function renderFiles(files) {
</button>
</h2>
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
aria-label="Show day highlights for ${esc(day)}">Highlights</button>` : ''}`;
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)
@@ -850,6 +902,7 @@ function renderFiles(files) {
if (!nowExp) {
dayFiles.forEach(f => closePlayer(f._idx));
document.getElementById('dayhl-' + dayId).hidden = true;
setHlExpanded(dayId, false);
if (dayActiveId === dayId) {
dayActiveSections = [];
dayActiveId = null;
@@ -858,10 +911,25 @@ function renderFiles(files) {
}
});
// Highlights button handler
// 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 hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
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);
});
}
@@ -874,6 +942,7 @@ async function dayHighlights(dayId, analyzableFiles) {
const btn = document.getElementById('dayhln-' + dayId);
hlRow.hidden = false;
setHlExpanded(dayId, true);
const n = analyzableFiles.length;
if (btn) btn.disabled = true;
@@ -912,12 +981,14 @@ async function dayHighlights(dayId, analyzableFiles) {
return;
}
// Map files onto the day timeline using mtime as file-end, duration for start
// 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 fileEnd = f.mtime;
const fileDur = data.duration || f.duration || 0;
const fileStart = fileEnd - fileDur;
return { f, data, fileStart, fileEnd, fileDur };
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) {
@@ -1005,7 +1076,9 @@ async function dayHighlights(dayId, analyzableFiles) {
const labels = document.createElement('div');
labels.className = 'day-tl-labels';
labels.innerHTML = `<span>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`;
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) {
@@ -1032,11 +1105,7 @@ async function dayHighlights(dayId, analyzableFiles) {
const c = document.createElement('button');
c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
const d = new Date(sec.absStart * 1000);
const hms = d.getHours().toString().padStart(2,'0') + ':'
+ d.getMinutes().toString().padStart(2,'0') + ':'
+ d.getSeconds().toString().padStart(2,'0');
c.textContent = hms + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c);
});
@@ -1051,6 +1120,11 @@ async function dayHighlights(dayId, analyzableFiles) {
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;
}