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:
@@ -38,10 +38,10 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
|
||||
- Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard).
|
||||
|
||||
`webui.html` (one `<script>` block):
|
||||
- Clip review: `clipQueue`/`clipCursor`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **Queue entries carry the filename, never a row index:** `_idx` is reassigned positionally on every `load()`, and `pollStatus()` reloads whenever a recording starts/stops/rolls over, so a stored index goes stale mid-review (a past bug had "Open in file"/cut fields target the row one off after a split rollover). Resolve at use time via `fileIdxByName()`. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false.
|
||||
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtime−duration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. **`aria-label` on a button replaces its visible content for screen readers** — any status text rendered inside a labelled button (like the analysed suffix) must be mirrored into the label (render path + the end of `dayHighlights()`), or the blind user never hears it. Likewise, the accessible text of a button built as `aria-hidden arrow span + text node` must not start the text node with a space (the hidden arrow drops out and the leading space reads as an indent) — keep separator spaces inside the aria-hidden span.
|
||||
- Clip review: `clipQueue`/`clipCursor`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **Queue entries carry the filename, never a row index:** `_idx` is reassigned positionally on every `load()`, and `pollStatus()` reloads whenever a recording starts/stops/rolls over, so a stored index goes stale mid-review (a past bug had "Open in file"/cut fields target the row one off after a split rollover). Resolve at use time via `fileIdxByName()`. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false. Both U/I walks — `stepClip`'s by-score branch and the in-player branch of the keydown handler — share `nextInOrder(order, pos, dir, jump)` for the stepping arithmetic (returns `null` at either end); they differ only in what anchors `pos` and what "play" means, and must not re-inline it.
|
||||
- Day review: `dayHighlights()` builds the chronological cross-file section list; `armDayQueue(dayId, sections)` is the single place that points the queue at a day (sets `dayActiveSections`/`dayActiveId`/`clipQueue` and resets both cursors) — used by the fresh-build path and the re-open-a-kept-panel path alike. `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtime−duration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. **`aria-label` on a button replaces its visible content for screen readers** — any status text rendered inside a labelled button (like the analysed suffix) must be mirrored into the label (render path + the end of `dayHighlights()`), or the blind user never hears it. Likewise, the accessible text of a button built as `aria-hidden arrow span + text node` must not start the text node with a space (the hidden arrow drops out and the leading space reads as an indent) — keep separator spaces inside the aria-hidden span.
|
||||
- J/K/U/I/O: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed; O calls `openClipInFile()` (shared with the "Open in file" button). J/K (and Prev/Next) always step in time order; U/I walk the loudest-first ranking from `scoreOrder()` — no top-N cutoff (the `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance/highlights setting). Auto-advance is the `input[name="clip-adv"]` radio (off / next in time / next by loudness), read by `advanceMode()`; `stepClip(dir, byScore)` is the shared queue-stepping path. In-player U/I anchor the ranking on the section under the playhead, else start at the loudest.
|
||||
- Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render: meta line with section count + params, then chips — no waveform SVG, see day-review note on the blind user), `cachedParamsMatch()` (autoload guard).
|
||||
- Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render: meta line with section count + params, then chips — no waveform SVG, see day-review note on the blind user; its `btn` argument is optional), `cachedParamsMatch()` (autoload guard). `analysisParams()` is the only reader of the three control inputs — no hardcoded client-side defaults, so web.py's constants stay the single source of truth via `/api/config`; `hlParams()` is just its values joined.
|
||||
|
||||
## Verifying changes
|
||||
|
||||
|
||||
+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