feat: split the web UI into modules and rework it for screen readers

webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.

How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
  wall-clock span ("22:00 to 23:00"), which is the row header and what
  every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
  finished analyses focus their first section, delete moves to the next
  row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
  per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
  spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
  lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
  player opens inside its cell; Download/Cut/Delete are always there,
  and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
  remembered per browser; no CSS uppercase on headers or badges.

Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 01:05:15 +02:00
co-authored by Claude Opus 5.5
parent c91264c253
commit 2a19515c27
19 changed files with 1639 additions and 1279 deletions
+145
View File
@@ -0,0 +1,145 @@
// Per-file loudness analysis: fetching (with a session cache) and the result
// cell — a meta line, the section chips, a Re-analyse button.
import {fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
import * as api from './api.js';
import {fileByName, fileByIdx, fileSpan, sectionMap} from './store.js';
import {analysisParams} from './settings.js';
import {playFrom} from './clips.js';
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has
const analysisCache = new Map();
export async function fetchAnalysis(filename, params, force = false) {
const {margin, minGap, minDur} = params;
const key = `${filename}|${margin}|${minGap}|${minDur}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key);
const d = await api.analyze(filename, params);
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 = fileByName(filename);
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
}
return d;
}
export function forgetAnalyses(filename) {
for (const k of [...analysisCache.keys()])
if (k.startsWith(filename + '|')) analysisCache.delete(k);
}
export function playFileSection(idx, si) {
const f = fileByIdx(idx);
if (!f) return;
const epoch = fileStartEpoch(f.date);
const queue = (sectionMap.get(idx) || []).map(s => ({
filename: f.name, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null,
}));
playFrom(queue, si);
}
export function analyseButton(idx, cell, text) {
const f = fileByIdx(idx);
const b = document.createElement('button');
b.textContent = text;
b.setAttribute('aria-label', `${text} ${f ? fileSpan(f) : ''}`);
b.addEventListener('click', () =>
analyse(idx, cell, b, {force: text === 'Re-analyse', speak: true}));
return b;
}
const para = (cls, text) => {
const p = document.createElement('p');
p.className = cls;
p.textContent = text;
return p;
};
// btn is the button that started it (optional). It stays in place and
// focusable while busy — disabling or replacing a focused button drops the
// screen reader at the top of the page. speak: the user asked for this one, so
// say how it went; background autoloads stay quiet.
export async function analyse(idx, cell, btn, {force = false, speak = false} = {}) {
const f = fileByIdx(idx);
if (!f || cell.dataset.busy) return;
cell.dataset.busy = '1';
const span = fileSpan(f);
if (btn) {
btn.textContent = 'Analysing…';
btn.setAttribute('aria-label', `Analysing ${span}`);
btn.setAttribute('aria-disabled', 'true');
} else {
cell.replaceChildren(para('quiet', 'Analysing…'));
}
const params = analysisParams();
let d;
try {
d = await fetchAnalysis(f.name, params, force);
} catch (e) {
d = {error: e.message};
}
delete cell.dataset.busy;
// Focus inside the cell is about to be destroyed: move it to the new content
const hadFocus = cell.contains(document.activeElement);
if (d.error) {
const b = analyseButton(idx, cell, 'Analyse');
cell.replaceChildren(para('quiet', `Error: ${d.error}`), b);
if (hadFocus) b.focus();
if (speak) announce(`Analysis of ${span} failed: ${d.error}`);
return;
}
const secs = d.sections || [];
const n = secs.length;
const box = document.createElement('div');
box.className = 'wbox';
box.append(para('analysis-meta', `${n} loud section${n !== 1 ? 's' : ''}`
+ ` · margin ${params.margin} dB · gap ${params.minGap} s · min ${params.minDur} s`
+ (d.cached ? ' · cached' : '')));
if (n) {
sectionMap.set(idx, secs);
const epoch = fileStartEpoch(f.date);
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Loud sections');
secs.forEach((s, si) => {
const c = document.createElement('button');
c.className = 'chip';
// Wall-clock time like the day highlights and the clip label; the
// in-file offset only matters for cutting and lives in the tooltip
c.textContent = (epoch != null ? fmtClock(epoch + s.start) : `${fmtDur(s.start)} – ${fmtDur(s.end)}`)
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
c.title = `${fmtDur(s.start)}–${fmtDur(s.end)} into ${f.name}`;
c.addEventListener('click', () => playFileSection(idx, si));
chips.appendChild(c);
});
const tog = chipToggle(chips, `${n} sections`);
if (tog) box.append(tog);
box.append(chips);
} else {
sectionMap.delete(idx);
}
box.append(analyseButton(idx, cell, 'Re-analyse'));
box.lastChild.classList.add('reanalyse-btn');
cell.replaceChildren(box);
// First control in reading order: the first chip, the chip toggle when the
// list is collapsed, or Re-analyse when there are no sections
if (hadFocus) box.querySelector('button').focus();
if (speak) announce(n ? `${n} loud section${n !== 1 ? 's' : ''} in ${span}` : `No loud sections in ${span}`);
}
// Run the deferred analyses of a freshly expanded day
export function autoloadDayAnalyses(dayEl) {
dayEl.querySelectorAll('td[data-autoload]').forEach(cell => {
cell.removeAttribute('data-autoload');
analyse(Number(cell.dataset.idx), cell, cell.querySelector('button'));
});
}