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:
+190
@@ -0,0 +1,190 @@
|
||||
// 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' : ''}.`);
|
||||
}
|
||||
Reference in New Issue
Block a user