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:
@@ -0,0 +1,89 @@
|
||||
// Section navigation from the keyboard (J/K/U/I/O) and from media keys —
|
||||
// headset buttons, AirPods, the lock screen — via the Media Session API.
|
||||
//
|
||||
// J/K = previous/next section in time order, U/I = up/down the loudest-first
|
||||
// ranking, O = open the current clip in the full file. Holding Shift jumps to
|
||||
// the extreme in that direction: Shift+J/K = first/last section in time,
|
||||
// Shift+U/I = loudest/quietest. The clip queue takes priority; with no queue
|
||||
// armed, the keys step the playhead of the open full-file player instead.
|
||||
|
||||
import {fmtDur, fmtClock, fileStartEpoch, announce} from './util.js';
|
||||
import {hasQueue, stepClip, openClipInFile, scoreOrder, nextInOrder} from './clips.js';
|
||||
import {activePlayerIdx, setCutFields} from './player.js';
|
||||
import {sectionMap, fileByIdx} from './store.js';
|
||||
import {getPreroll} from './settings.js';
|
||||
|
||||
export function navigate(dir, byScore, jump) {
|
||||
if (hasQueue()) { stepClip(dir, byScore, jump); return; }
|
||||
|
||||
// Per-file in-player navigation (full-file listening, no clip queue)
|
||||
const idx = activePlayerIdx;
|
||||
if (idx === null) return;
|
||||
const all = sectionMap.get(idx) || [];
|
||||
const audio = document.getElementById('aud-' + idx);
|
||||
if (!all.length || !audio) return;
|
||||
const preroll = getPreroll();
|
||||
const cur = audio.currentTime;
|
||||
const epoch = fileStartEpoch(fileByIdx(idx)?.date);
|
||||
|
||||
const jumpTo = (s, i, n, word) => {
|
||||
audio.currentTime = Math.max(0, s.start - preroll);
|
||||
setCutFields(idx, s.start, s.end);
|
||||
const when = epoch != null
|
||||
? `${fmtClock(epoch + s.start)} to ${fmtClock(epoch + s.end)}`
|
||||
: `${fmtDur(s.start)} to ${fmtDur(s.end)}`;
|
||||
announce(`${word} ${i + 1} of ${n}: ${when}`);
|
||||
};
|
||||
|
||||
if (byScore) {
|
||||
// U/I walk the ranking; the anchor is the section the playhead sits in
|
||||
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
|
||||
// Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I).
|
||||
const order = scoreOrder(all);
|
||||
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5);
|
||||
const pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
|
||||
const next = nextInOrder(order, pos, dir, jump);
|
||||
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
|
||||
jumpTo(all[order[next]], next, order.length, 'Highlight');
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+J/K jump to the first/last section regardless of the playhead.
|
||||
if (jump) {
|
||||
const i = dir < 0 ? 0 : all.length - 1;
|
||||
jumpTo(all[i], i, all.length, 'Section');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dir < 0) {
|
||||
for (let i = all.length - 1; i >= 0; i--)
|
||||
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
announce('Beginning of sections');
|
||||
} else {
|
||||
for (let i = 0; i < all.length; i++)
|
||||
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
announce('End of sections');
|
||||
}
|
||||
}
|
||||
|
||||
export function initNav() {
|
||||
document.addEventListener('keydown', e => {
|
||||
const t = e.target;
|
||||
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (!['j', 'k', 'u', 'i', 'o'].includes(key)) return;
|
||||
e.preventDefault();
|
||||
if (key === 'o') { openClipInFile(); return; }
|
||||
navigate(key === 'j' || key === 'u' ? -1 : 1, key === 'u' || key === 'i', e.shiftKey);
|
||||
});
|
||||
|
||||
// Previous/next track = J/K. Works from AirPods, the iOS lock screen and
|
||||
// Control Center, and the media keys on a Windows keyboard.
|
||||
if ('mediaSession' in navigator) {
|
||||
for (const [action, dir] of [['previoustrack', -1], ['nexttrack', 1]]) {
|
||||
try { navigator.mediaSession.setActionHandler(action, () => navigate(dir, false, false)); }
|
||||
catch { /* action not supported here */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user