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:
2026-10-18 01:05:15 +02:00
co-authored by Claude Opus 5.5
parent c91264c253
commit 2a19515c27
19 changed files with 1639 additions and 1279 deletions
+193
View File
@@ -0,0 +1,193 @@
// The clip player: sections play as small server-rendered WAV clips
// (/api/clip) in the bottom bar instead of seeking the full recording, which
// is slow for big FLACs. The queue holds the active review list (one file's
// sections, or a whole day's); J/K and Prev/Next step through it in time
// order, U/I step through the loudest-first ranking. The auto-advance radio
// picks what plays when a clip ends: nothing, the next in time, or the next
// by loudness.
//
// Two cursors, deliberately independent. clipCursor is the time-order position
// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness
// walk's own position in scoreOrder(): only U/I (and "next loudest" auto-
// advance) move it; J/K detours to review nearby clips leave it alone, so
// coming back to U/I resumes the ranking exactly where you left it. An explicit
// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there.
import {$, fmtDur, fmtClock, announce} from './util.js';
import {fileIdxByName} from './store.js';
import {advanceMode, getPreroll} from './settings.js';
import {clipUrl} from './api.js';
import {setCutFields, seekToSection} from './player.js';
let clipQueue = [];
let clipCursor = -1;
let scoreCursor = -1;
// Where focus was when the bar opened, so closing it doesn't drop the user at
// the top of the page
let returnFocus = null;
export const hasQueue = () => clipQueue.length > 0;
// byScore: this play is part of a loudness walk (U/I or "next loudest"
// auto-advance), so the position shown is scoreCursor — the rank in the
// loudest-first ranking, labelled "by loudness" — not the time-order index.
export function playClip(i, byScore) {
if (i < 0 || i >= clipQueue.length) return;
clipCursor = i;
const c = clipQueue[i];
const cs = Math.max(0, c.start - getPreroll());
const ce = c.end + 1.5; // small tail after the section
const a = $('clip-audio');
a.src = clipUrl(c.filename, cs, ce);
a.play().catch(() => {});
// Label = wall-clock time of occurrence (absStart from the filename clock);
// falls back to in-file offsets for non-standard filenames. The visible
// label, the screen-reader announcement and the lock-screen title use one
// identical string: "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" —
// the count is the time-order index "by time" (J/K) or the loudness rank
// "by loudness" (U/I).
const when = c.absStart != null
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
const score = c.score != null ? ` · +${Math.round(c.score)} dB` : '';
const pos = byScore ? scoreCursor : i;
const dim = byScore ? 'by loudness' : 'by time';
const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`;
const label = $('clip-label');
label.textContent = text;
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
const bar = $('clip-bar');
const ae = document.activeElement;
if (ae && ae !== document.body && !bar.contains(ae)) returnFocus = ae;
bar.hidden = false;
document.body.classList.add('clip-open');
const rowIdx = fileIdxByName(c.filename);
if (rowIdx != null) setCutFields(rowIdx, c.start, c.end);
if ('mediaSession' in navigator && 'MediaMetadata' in window)
navigator.mediaSession.metadata = new MediaMetadata({title: text, artist: 'ISR', album: c.filename});
announce(text);
}
export function hideClipBar() {
const a = $('clip-audio');
a.pause();
a.removeAttribute('src');
$('clip-bar').hidden = true;
document.body.classList.remove('clip-open');
if ('mediaSession' in navigator) navigator.mediaSession.metadata = null;
clipQueue = [];
clipCursor = -1;
scoreCursor = -1;
}
// Point the queue at a new section list without playing: nothing is selected
// yet, and the next U/I must re-anchor here rather than resume a walk through
// the previous queue.
export function armQueue(sections) {
clipQueue = sections;
clipCursor = -1;
scoreCursor = -1;
}
// Explicit jump (chip click): play section i of sections; the next U/I
// re-anchors on it
export function playFrom(sections, i) {
clipQueue = sections;
scoreCursor = -1;
playClip(i);
}
// Highlight order = section indices sorted loudest-first. U/I (and "next
// loudest" auto-advance) walk this ranking, so a review runs from the most to
// the least interesting event until the user stops — no top-N cutoff.
// Cached against the section array's identity: a day queue runs to thousands
// of entries and U/I would otherwise re-sort all of them on every keypress.
// Every arming path assigns a fresh array, so identity is a safe key.
let scoreOrderCache = {secs: null, order: null};
export function scoreOrder(secs) {
if (scoreOrderCache.secs === secs) return scoreOrderCache.order;
const order = secs.map((s, i) => ({i, score: s.score || 0}))
.sort((a, b) => b.score - a.score)
.map(r => r.i);
scoreOrderCache = {secs, order};
return order;
}
// Step a position within an ordering: pos + dir, or straight to the extreme in
// that direction when jump. Returns null when the step runs off either end.
// pos === -1 means "not anchored yet" — start at the first entry going forward,
// or off the front going backward so the caller announces the boundary.
// Shared by both U/I walks (clip queue and in-player); they differ only in what
// the anchor is and what "play" means, and drifting apart is exactly how the
// two would stop agreeing.
export function nextInOrder(order, pos, dir, jump) {
const next = jump ? (dir > 0 ? order.length - 1 : 0)
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
return (next >= 0 && next < order.length) ? next : null;
}
// Step the clip queue by dir (±1): in time order, or with byScore down/up
// the loudest-first ranking (next = next quieter). With jump=true, go straight
// to the extreme in that direction (first/last section, or loudest/quietest).
export function stepClip(dir, byScore, jump) {
if (!clipQueue.length) return;
if (byScore) {
// Walk scoreCursor, not the shared clipCursor: a J/K detour never moved
// it, so we resume the ranking where we left off. scoreCursor === -1 means
// the walk hasn't started since the queue was armed or an explicit jump —
// anchor on the section currently selected (clipCursor), else the loudest.
const order = scoreOrder(clipQueue);
const pos = scoreCursor !== -1 ? scoreCursor
: clipCursor >= 0 ? order.indexOf(clipCursor)
: -1;
const next = nextInOrder(order, pos, dir, jump);
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
scoreCursor = next;
playClip(order[next], true);
return;
}
// J/K (time order) deliberately does NOT touch scoreCursor.
const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir;
if (i >= 0 && i < clipQueue.length) playClip(i);
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
}
// "Open in file": switch from the current clip to the full recording at the
// same position. Bound to the clip-bar button and the O key.
export function openClipInFile() {
const c = clipQueue[clipCursor];
if (!c) return;
const idx = fileIdxByName(c.filename);
if (idx == null) return;
$('clip-audio').pause();
const row = $('row-' + idx);
if (row) {
const wrap = row.closest('.table-wrap');
if (wrap?.hidden) // file's day is collapsed: expand it
$('daytgl-' + wrap.id.slice('daytbl-'.length))?.click();
row.scrollIntoView({block: 'center'});
}
seekToSection(idx, c.start, c.end);
}
function closeClipBar() {
const back = returnFocus;
returnFocus = null;
hideClipBar();
(back?.isConnected ? back : $('page-title')).focus();
}
export function initClipBar() {
$('clip-prev').addEventListener('click', () => stepClip(-1, false));
$('clip-next').addEventListener('click', () => stepClip(1, false));
$('clip-close').addEventListener('click', closeClipBar);
$('clip-context').addEventListener('click', openClipInFile);
$('clip-audio').addEventListener('ended', () => {
const mode = advanceMode();
if (mode !== 'off') stepClip(1, mode === 'hl');
});
$('clip-bar').addEventListener('keydown', e => {
if (e.key === 'Escape') { e.preventDefault(); closeClipBar(); }
});
}