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

191 lines
7.3 KiB
JavaScript

// Day review: the chronological cross-file section list behind a day's
// Highlights button.
//
// The user is blind and uses a screen reader — there is deliberately no day
// timeline graphic. The panel is linear text and buttons: summary line ->
// key-hint note -> chips toggle -> chips.
import {$, fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
import {fetchAnalysis} from './analysis.js';
import {armQueue, playFrom, hideClipBar} from './clips.js';
import {analysisParams, hlParams} from './settings.js';
let dayActiveSections = [];
let dayActiveId = null;
// dayId -> section list of an already-built highlights panel, so collapsing
// and re-expanding it re-arms J/K without recomputing
const dayHlSections = new Map();
export function setHlExpanded(dayId, exp) {
const btn = $('dayhln-' + dayId);
if (!btn) return;
btn.setAttribute('aria-expanded', exp);
const arrow = btn.querySelector('.day-arrow');
if (arrow) arrow.textContent = exp ? '▾ ' : '▸ ';
}
// The status suffix inside the Highlights button (" · analysed", progress).
// The button has no aria-label, so this text is part of its accessible name.
const setHlStatus = (dayId, text) => {
const el = $('dayhls-' + dayId);
if (el) el.textContent = text;
};
// 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. Coupled globals set in
// two places is how the stale-_idx and diverging-label bugs happened.
function armDayQueue(dayId, sections) {
dayActiveSections = sections;
dayActiveId = dayId;
armQueue(sections);
}
function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return;
playFrom(dayActiveSections, si);
}
// The day was collapsed: close its panel and drop its queue
export function releaseDay(dayId) {
const panel = $('dayhl-' + dayId);
if (panel) panel.hidden = true;
setHlExpanded(dayId, false);
if (dayActiveId === dayId) {
dayActiveSections = [];
dayActiveId = null;
hideClipBar();
}
}
// Highlights button: expand/collapse the panel; only (re)compute when it has
// not been built yet this session or the analysis params changed
export function toggleHighlights(dayId, files) {
const panel = $('dayhl-' + dayId);
const btn = $('dayhln-' + dayId);
if (btn?.dataset.busy) return;
if (!panel.hidden) { // collapse, keep the panel
panel.hidden = true;
setHlExpanded(dayId, false);
return;
}
if (panel.dataset.loaded === hlParams()) { // re-open and re-arm J/K
panel.hidden = false;
setHlExpanded(dayId, true);
armDayQueue(dayId, dayHlSections.get(dayId) || []);
return;
}
dayHighlights(dayId, files);
}
async function dayHighlights(dayId, files) {
const panel = $('dayhl-' + dayId);
const content = $('dayhlc-' + dayId);
const btn = $('dayhln-' + dayId);
const n = files.length;
panel.hidden = false;
setHlExpanded(dayId, true);
// Busy, not disabled: disabling the focused button would throw the screen
// reader's focus to the top of the page
btn.dataset.busy = '1';
btn.setAttribute('aria-disabled', 'true');
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
const progFill = document.createElement('div'); progFill.className = 'prog-fill';
progBar.setAttribute('aria-hidden', 'true');
progBar.appendChild(progFill);
const progFile = document.createElement('p'); progFile.className = 'prog-file';
content.replaceChildren(progBar, progFile);
const params = analysisParams();
const results = [];
let nCached = 0;
for (let i = 0; i < n; i++) {
const f = files[i];
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
progFill.style.width = `${(i / n) * 100}%`;
setHlStatus(dayId, ` · ${i + 1} of ${n}`);
try {
const d = await fetchAnalysis(f.name, params);
if (!d.error) { results.push({f, data: d}); if (d.cached) nCached++; }
} catch { /* counted as missing below */ }
}
delete btn.dataset.busy;
btn.removeAttribute('aria-disabled');
// Every file is now cached with the current params
setHlStatus(dayId, results.length === n ? ' · analysed' : '');
// Map files onto the day. The filename is the clock: f.date is the
// recording start parsed server-side; mtime (≈ file end) is only a fallback
// for non-standard names.
const sections = [];
results.forEach(({f, data}) => {
const fileDur = data.duration || f.duration || 0;
if (fileDur <= 0) return;
const startEpoch = fileStartEpoch(f.date);
const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur;
(data.sections || []).forEach(s => sections.push({
filename: f.name, start: s.start, end: s.end, score: s.score,
absStart: fileStart + s.start,
}));
});
sections.sort((a, b) => a.absStart - b.absStart);
// Arm the clip queue so J/K steps through the day immediately
armDayQueue(dayId, sections);
const box = document.createElement('div');
box.className = 'wbox';
const nRes = results.length, nSec = sections.length;
const missing = n - nRes;
// Totals first, then the key hint, then the chips: linear reading order
const summary = document.createElement('p');
summary.className = 'quiet';
summary.textContent = `${nRes} recording${nRes !== 1 ? 's' : ''} analysed`
+ (nCached ? ` (${nCached} from cache)` : '')
+ (missing ? `, ${missing} failed` : '')
+ ` · ${nSec} loud section${nSec !== 1 ? 's' : ''}`;
box.appendChild(summary);
if (nSec) {
const note = document.createElement('p');
note.className = 'quiet';
note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first). Hold Shift to jump to the first / last section (Shift + J / K) or the loudest / quietest (Shift + U / I)';
box.appendChild(note);
const MAX_DAY_CHIPS = 50;
// When there are too many sections to show them all, show the ones most
// worth reviewing: the top MAX_DAY_CHIPS by score, loudest first.
let chipList = sections.map((sec, si) => ({sec, si}));
const truncated = chipList.length > MAX_DAY_CHIPS;
if (truncated) {
chipList = chipList
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
.slice(0, MAX_DAY_CHIPS);
}
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections');
chipList.forEach(({sec, si}) => {
const c = document.createElement('button');
c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c);
});
const tog = chipToggle(chips, truncated
? `Top ${chipList.length} sections by loudness`
: `${chipList.length} sections`);
if (tog) box.appendChild(tog);
box.appendChild(chips);
}
content.replaceChildren(box);
panel.dataset.loaded = hlParams();
dayHlSections.set(dayId, sections);
announce(nSec
? `${nSec} loud section${nSec !== 1 ? 's' : ''} across ${nRes} recording${nRes !== 1 ? 's' : ''}. K plays the first, I the loudest.`
: `No loud sections in ${nRes} recording${nRes !== 1 ? 's' : ''}.`);
}