Files
adminandClaude Opus 5.5 2a19515c27 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>
2026-10-18 01:05:15 +02:00

146 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'));
});
}