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:
2026-07-29 21:15:49 +02:00
co-authored by Claude Opus 5
parent 625ee0e7db
commit 0e7ea82da6
2 changed files with 85 additions and 64 deletions
+82 -61
View File
@@ -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