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>
146 lines
5.4 KiB
JavaScript
146 lines
5.4 KiB
JavaScript
// 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'));
|
||
});
|
||
}
|