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>
191 lines
7.3 KiB
JavaScript
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' : ''}.`);
|
|
}
|