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>
92 lines
3.1 KiB
JavaScript
92 lines
3.1 KiB
JavaScript
// The per-row full-file player and its cut fields. Section review normally
|
|
// goes through clips.js; this is for listening around a section ("Open in
|
|
// file") or to a whole recording.
|
|
|
|
import {$, fmtDur} from './util.js';
|
|
import {fileByIdx, fileSpan} from './store.js';
|
|
import {streamUrl} from './api.js';
|
|
import {getPreroll} from './settings.js';
|
|
|
|
// Row of the full-file player opened last. The J/K/U/I fallback steps through
|
|
// its sections when no clip queue is armed. Live binding — read, don't assign.
|
|
export let activePlayerIdx = null;
|
|
|
|
export function forgetPlayer(idx) {
|
|
if (activePlayerIdx === idx) activePlayerIdx = null;
|
|
}
|
|
|
|
export function setCutFields(idx, startSec, endSec) {
|
|
const startEl = $('cut-start-' + idx);
|
|
const endEl = $('cut-end-' + idx);
|
|
if (startEl) startEl.value = fmtDur(startSec);
|
|
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
|
|
}
|
|
|
|
const isOpen = idx => $('pbtn-' + idx)?.getAttribute('aria-expanded') === 'true';
|
|
|
|
function setPlayButton(idx, open) {
|
|
const btn = $('pbtn-' + idx);
|
|
const f = fileByIdx(idx);
|
|
if (!btn || !f) return;
|
|
btn.setAttribute('aria-expanded', open);
|
|
btn.textContent = open ? 'Hide' : 'Play';
|
|
btn.setAttribute('aria-label', (open ? 'Hide player for ' : 'Play ') + fileSpan(f));
|
|
}
|
|
|
|
// The player lives in its own table cell: the Play button stays, and the
|
|
// audio element appears right after it
|
|
export function closePlayer(idx) {
|
|
const audio = $('aud-' + idx);
|
|
if (!audio || audio.hidden) return;
|
|
audio.pause();
|
|
audio.hidden = true;
|
|
setPlayButton(idx, false);
|
|
}
|
|
|
|
export function togglePlayer(idx) {
|
|
if (isOpen(idx)) { closePlayer(idx); return; }
|
|
const f = fileByIdx(idx);
|
|
const audio = $('aud-' + idx);
|
|
if (!f || !audio) return;
|
|
if (!audio.dataset.srcSet) {
|
|
audio.preload = 'auto';
|
|
audio.src = streamUrl(f.name);
|
|
audio.load();
|
|
audio.dataset.srcSet = '1';
|
|
} else if (!$('rec-' + idx)?.hidden) {
|
|
// Still recording: re-fetch so duration and seek range cover the audio
|
|
// captured since the source was last loaded
|
|
audio.load();
|
|
}
|
|
activePlayerIdx = idx;
|
|
audio.hidden = false;
|
|
setPlayButton(idx, true);
|
|
audio.focus();
|
|
}
|
|
|
|
export function seekToSection(idx, startSec, endSec) {
|
|
if (!isOpen(idx)) togglePlayer(idx);
|
|
activePlayerIdx = idx;
|
|
const audio = $('aud-' + idx);
|
|
if (!audio) return;
|
|
audio.preload = 'auto';
|
|
const seekTo = Math.max(0, startSec - getPreroll());
|
|
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
|
if (audio.readyState >= 1) doSeek();
|
|
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
|
setCutFields(idx, startSec, endSec);
|
|
}
|
|
|
|
// Cut button: show/hide the start/end fields inside the options cell. Opening
|
|
// puts focus in Start. Clip playback pre-fills the fields either way.
|
|
export function toggleCut(idx) {
|
|
const btn = $('cutbtn-' + idx);
|
|
const panel = $('cut-' + idx);
|
|
if (!btn || !panel) return;
|
|
const open = panel.hidden;
|
|
panel.hidden = !open;
|
|
btn.setAttribute('aria-expanded', open);
|
|
btn.querySelector('.day-arrow').textContent = open ? '▾ ' : '▸ ';
|
|
if (open) $('cut-start-' + idx)?.focus();
|
|
}
|