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:
+94
-20
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user