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>
This commit is contained in:
+82
-61
@@ -61,15 +61,14 @@ a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-rad
|
||||
.actions{display:flex;gap:6px;align-items:center}
|
||||
button.del{color:var(--red);border-color:#7f1d1d}
|
||||
button.del:hover:not(:disabled){background:#2d0808}
|
||||
/* waveform */
|
||||
/* 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}
|
||||
button.chip{cursor:pointer}
|
||||
button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||||
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
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}
|
||||
@@ -240,9 +239,8 @@ function setCutFields(idx, startSec, endSec) {
|
||||
// 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 -> filename, for live-status polling
|
||||
const recMap = new Map();
|
||||
// idx -> [{start,end}], populated after analysis
|
||||
const sectionMap = new Map();
|
||||
let activePlayerIdx = null;
|
||||
@@ -257,11 +255,19 @@ let dayActiveId = null;
|
||||
// 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('|');
|
||||
// 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);
|
||||
@@ -290,7 +296,7 @@ function closePlayer(idx) {
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded','false');
|
||||
btn.textContent = 'Play';
|
||||
btn.setAttribute('aria-label','Play '+(recMap.get(idx) || ''));
|
||||
btn.setAttribute('aria-label','Play '+fileNameByIdx(idx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +434,19 @@ function scoreOrder(secs) {
|
||||
.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';
|
||||
|
||||
@@ -442,15 +461,13 @@ function stepClip(dir, byScore, jump) {
|
||||
// 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);
|
||||
let next;
|
||||
if (jump) next = dir > 0 ? order.length - 1 : 0;
|
||||
else if (scoreCursor !== -1) next = scoreCursor + dir;
|
||||
else {
|
||||
const anchor = clipCursor >= 0 ? order.indexOf(clipCursor) : -1;
|
||||
next = anchor === -1 ? (dir > 0 ? 0 : -1) : anchor + dir;
|
||||
}
|
||||
if (next >= 0 && next < order.length) { scoreCursor = next; playClip(order[next], true); }
|
||||
else announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
|
||||
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.
|
||||
@@ -507,17 +524,20 @@ async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
|
||||
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) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
if (btn) { 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 {margin, minGap, minDur} = analysisParams();
|
||||
const restoreBtn = () => {
|
||||
btn.textContent = 'Analyse'; btn.disabled = false;
|
||||
btn.onclick = () => analyse(idx, filename, cell, btn);
|
||||
if (!cell.contains(btn)) cell.appendChild(btn);
|
||||
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);
|
||||
@@ -611,12 +631,12 @@ document.addEventListener('keydown', e => {
|
||||
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 = jump ? (dir > 0 ? order.length - 1 : 0)
|
||||
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
|
||||
if (next >= 0 && next < order.length)
|
||||
jumpTo(all[order[next]], next, order.length, 'Highlight');
|
||||
else
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -648,7 +668,6 @@ async function deleteFile(idx, filename) {
|
||||
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();
|
||||
@@ -689,12 +708,11 @@ function cachedParamsMatch(ca) {
|
||||
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);
|
||||
analyse(Number(cell.dataset.idx), cell.dataset.fname, cell, cell.querySelector('button'));
|
||||
});
|
||||
}
|
||||
|
||||
function _attachFileRowHandlers(f, isRec, expanded, dayId) {
|
||||
function _attachFileRowHandlers(f, isRec, expanded) {
|
||||
const i = f._idx;
|
||||
const ext = f.ext;
|
||||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||||
@@ -754,14 +772,11 @@ function _attachFileRowHandlers(f, isRec, expanded, dayId) {
|
||||
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;
|
||||
@@ -914,7 +929,7 @@ function renderFiles(files) {
|
||||
</td>`;
|
||||
dayTbody.appendChild(prow);
|
||||
|
||||
_attachFileRowHandlers(f, isRec, expanded, dayId);
|
||||
_attachFileRowHandlers(f, isRec, expanded);
|
||||
});
|
||||
|
||||
// Day toggle handler
|
||||
@@ -952,11 +967,7 @@ function renderFiles(files) {
|
||||
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;
|
||||
scoreCursor = -1;
|
||||
armDayQueue(dayId, dayHlSections.get(dayId) || []);
|
||||
return;
|
||||
}
|
||||
dayHighlights(dayId, hlFiles);
|
||||
@@ -985,9 +996,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
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 {margin, minGap, minDur} = analysisParams();
|
||||
|
||||
const results = [];
|
||||
let nCached = 0, nLive = 0;
|
||||
@@ -1029,10 +1038,10 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
const totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
|
||||
|
||||
// Build cross-file section list for J/K navigation and chips
|
||||
dayActiveSections = [];
|
||||
const sections = [];
|
||||
positioned.forEach(({ f, data, fileStart }) => {
|
||||
(data.sections || []).forEach(s => {
|
||||
dayActiveSections.push({
|
||||
sections.push({
|
||||
filename: f.name,
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
@@ -1041,12 +1050,9 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
});
|
||||
});
|
||||
});
|
||||
dayActiveSections.sort((a, b) => a.absStart - b.absStart);
|
||||
dayActiveId = dayId;
|
||||
sections.sort((a, b) => a.absStart - b.absStart);
|
||||
// Arm the clip queue so J/K steps through the day immediately
|
||||
clipQueue = dayActiveSections;
|
||||
clipCursor = -1;
|
||||
scoreCursor = -1;
|
||||
armDayQueue(dayId, sections);
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'wbox';
|
||||
@@ -1130,6 +1136,19 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
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;
|
||||
@@ -1176,9 +1195,11 @@ 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);
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user