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:
@@ -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'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user