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>
194 lines
8.3 KiB
JavaScript
194 lines
8.3 KiB
JavaScript
// 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(); }
|
||
});
|
||
}
|