Files
ISR/webui/js/nav.js
T
adminandClaude Opus 5.5 2a19515c27 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>
2026-10-18 01:05:15 +02:00

90 lines
3.8 KiB
JavaScript

// 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 */ }
}
}
}