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>
116 lines
4.5 KiB
JavaScript
116 lines
4.5 KiB
JavaScript
// Formatting and screen-reader helpers shared by every module. A leaf: it
|
|
// imports nothing, so any module can use it without creating a cycle.
|
|
|
|
export const $ = id => document.getElementById(id);
|
|
|
|
export const esc = s => String(s)
|
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
export const pad = n => String(n).padStart(2, '0');
|
|
|
|
export const fmtDur = s => {
|
|
if (s == null) return '—';
|
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
|
|
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
|
};
|
|
|
|
// "2 h 59 min" — for running text a screen reader speaks, where "2:59:59"
|
|
// reads like a clock time
|
|
export const fmtDurWords = s => {
|
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
|
if (h) return m ? `${h} h ${m} min` : `${h} h`;
|
|
return m ? `${m} min` : `${Math.round(s)} s`;
|
|
};
|
|
|
|
export const fmtSize = b => {
|
|
if (b < 1024) return b + ' B';
|
|
if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB';
|
|
if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB';
|
|
return (b / (1 << 30)).toFixed(2) + ' GB';
|
|
};
|
|
|
|
// Epoch seconds -> local wall-clock "HH:MM:SS"
|
|
export const fmtClock = ts => {
|
|
const d = new Date(ts * 1000);
|
|
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
|
};
|
|
|
|
// Epoch seconds -> local "HH:MM"
|
|
export const fmtHM = ts => {
|
|
const d = new Date(ts * 1000);
|
|
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
};
|
|
|
|
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
|
|
// the filename server-side) -> epoch seconds, or null for unparseable values
|
|
export const fileStartEpoch = date => {
|
|
const t = Date.parse(String(date).replace(' ', 'T'));
|
|
return isNaN(t) ? null : t / 1000;
|
|
};
|
|
|
|
// "1:30", "0:01:30" or "90" -> seconds; null when unparseable
|
|
export function parseTime(s) {
|
|
if (!s || !s.trim()) return null;
|
|
const parts = s.trim().split(':').map(v => parseFloat(v));
|
|
if (parts.some(isNaN) || parts.length > 3) return null;
|
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
return parts[0];
|
|
}
|
|
|
|
// "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the
|
|
// ISO date, which screen readers read digit group by digit group. en-GB to
|
|
// match the page's lang="en", so the speech language never switches mid-page.
|
|
export function dayLabel(day) {
|
|
const [y, m, d] = day.split('-').map(Number);
|
|
const date = new Date(y, m - 1, d);
|
|
if (isNaN(date)) return day;
|
|
const now = new Date();
|
|
const opts = {weekday: 'long', day: 'numeric', month: 'long'};
|
|
if (y !== now.getFullYear()) opts.year = 'numeric';
|
|
const text = date.toLocaleDateString('en-GB', opts);
|
|
// Math.round absorbs the 23/25 h days around DST switches
|
|
const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000);
|
|
return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text;
|
|
}
|
|
|
|
export function announce(msg) {
|
|
const el = $('sr-announce');
|
|
if (!el) return;
|
|
el.textContent = ''; // clear first so same text re-triggers
|
|
setTimeout(() => { el.textContent = msg; }, 50);
|
|
}
|
|
|
|
// A long chip list is a wall of buttons: hide it behind an aria-expanded
|
|
// toggle. Returns the toggle to insert before the chips, or null when the list
|
|
// is short enough to show as is. The separator space lives inside the
|
|
// aria-hidden arrow — a leading space in the text node reads as an indent.
|
|
export function chipToggle(chips, text) {
|
|
if (chips.children.length <= 12) return null;
|
|
chips.hidden = true;
|
|
const tog = document.createElement('button');
|
|
tog.className = 'chip-toggle';
|
|
tog.setAttribute('aria-expanded', 'false');
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'day-arrow';
|
|
arrow.setAttribute('aria-hidden', 'true');
|
|
arrow.textContent = '▸ ';
|
|
tog.append(arrow, text);
|
|
tog.addEventListener('click', () => {
|
|
const exp = chips.hidden;
|
|
chips.hidden = !exp;
|
|
tog.setAttribute('aria-expanded', exp);
|
|
arrow.textContent = exp ? '▾ ' : '▸ ';
|
|
});
|
|
return tog;
|
|
}
|
|
|
|
// localStorage for per-browser conveniences (pre-roll, auto-advance, whether
|
|
// the settings are open). It can be missing or throw (private mode, blocked
|
|
// storage), and nothing here may depend on it working.
|
|
export const remember = {
|
|
get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } },
|
|
set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } },
|
|
};
|