Files
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

194 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(); }
});
}